Compare commits

..
17 Commits
Author SHA1 Message Date
cayce 90e6cfcc19 fix: address external audit — criticals 1-5 + mediums
Critical 1 (approval forgery): audit_log is now hash-chained
(prev_hash/entry_hash, verify_audit_chain); executor approve REQUIRES a
BIP-340 signature over sha256(cayce-ops:approve:<id>) when
CAYCE_OPS_OPERATOR_PUBKEY is set; deploy/roles.sql ships least-privilege
per-daemon DB roles.

Critical 2 (threading dead): sends create real OUTBOUND rows (own
Message-ID, In-Reply-To -> inbound); inbound message_id never overwritten;
maild compose adds the first-contact path; _match_proposal falls back to
inbound chains.

Critical 3 (duplicate sends): per-message transactions in send path —
SMTP failure on message N no longer resurrects 1..N-1; failures audited
(send_failed) and the row stays queued.

Critical 4 (classifier): negated approvals (cannot accept / not approved /
not accepting) classify REJECTION at 0.95 before the bare-keyword rule;
LLM prompt braces escaped (.format no longer KeyError into silent fallback).

Critical 5 (systemd): every daemon has a run subcommand
(maild run = poll+send, publisher run = publish-due, monitor run = build);
deploy README documents the separate daily publisher plan timer.

Mediums: approved_hash binds drafts/captions to the approved text (edited
text refuses to send/publish until re-approved); risk_score renamed
match_score (migration 0002) and auto-approve orders HIGHEST relevance
first; compose binds postgres to 127.0.0.1; IMAP marks \\Seen only after
commit (fetch returns (num, raw), mark post-commit); disabled channels are
terminal (ContentStatus.SKIPPED) and publish failures burn a bounded
attempt budget before abandonment; make hooks installs the pre-push gate.

tests: 12 new — 56 total, all green; ruff 0 findings; gitleaks clean.
2026-10-19 23:06:29 +00:00
cayce 2da5f3ff24 Merge PR #8: audit update — audited commit pinned, gap disclosed
ci / lint-test-leaks (push) Canceled after 0s
2026-10-19 22:04:35 +00:00
cayce 2423cd10b8 audit: pin audited commit d500d88 + disclose lint-gate gap and fix
ci / lint-test-leaks (pull_request) Canceled after 0s
2026-10-19 22:04:34 +00:00
cayce d500d88870 Merge PR #7: lint-fix — make check fully green (E501/F841/F821/E731)
ci / lint-test-leaks (push) Canceled after 0s
Closes the gate gap flagged during final verification: prior per-branch
runs read ruff tail lines incorrectly. This commit is the audited one.
2026-10-19 22:03:55 +00:00
cayce a282aedb94 lint: wrap long lines (E501), fix F841/F821/E731 — make check fully green
All 32 findings resolved: regex literals kept single-line with noqa
(pattern IS the reviewed artifact), HTML rows string-concatenated for
line length, walrus tuple in BIP-340 verify split into named generator,
unused pytest import dropped. No behavior changes; 44/44 tests pass.
Full-gate discipline restored: ruff exit 0.
2026-10-19 22:03:53 +00:00
cayce abbaf7e4e2 Merge PR #6: audit — AUDIT.md for operator review
ci / lint-test-leaks (push) Canceled after 0s
2026-10-19 21:58:47 +00:00
cayce 6d77b3eff6 audit: AUDIT.md for operator review
ci / lint-test-leaks (pull_request) Canceled after 0s
Component map + data flow, every external call (where/what/trigger),
human-gate vs AUTONOMOUS_MODE matrix, env+permissions per component,
pinned deps with purpose, known risks (8, each with mitigation),
test+stack instructions. Audited-commit hash lands in the issue.
2026-10-19 21:58:46 +00:00
cayce 3f07362b7a Merge PR #5: monitor
ci / lint-test-leaks (push) Canceled after 0s
Static status page with privacy assertions and full escaping. 44 tests green.
2026-10-19 21:57:32 +00:00
cayce f61372e28e monitor: static status page — health, queue, gates, audit tail
ci / lint-test-leaks (pull_request) Canceled after 0s
- self-contained HTML (no external assets/JS), everything html-escaped
- daemon health from daemon_runs (last status + last run per daemon)
- proposal queue counts by state; human-gate counters (mail drafts awaiting
  approval, content draft/approved)
- audit tail: actor/action/result only — no detail payloads, no addresses,
  no subjects, no content bodies (tests assert the negatives)
- DaemonRun bookkeeping like every other daemon

tests: 3 new (sections render, leak-negatives, XSS escape) — 44 total
2026-10-19 21:57:31 +00:00
cayce 25c7db3a31 Merge PR #4: publisher
ci / lint-test-leaks (push) Canceled after 0s
Themed calendar + NIP-01 nostr (deterministic schnorr, self-verify),
FB/IG adapters disabled by default, hard human gate. 41 tests green.
2026-10-19 21:54:48 +00:00
cayce aabd6bafc7 publisher: themed calendar, NIP-01 nostr adapter, gated brand images
ci / lint-test-leaks (pull_request) Canceled after 0s
- calendar: one draft per theme/channel (idempotent), rotated schedule
- human gate: approve() audited; AUTONOMOUS_MODE has no effect here by design
- nostr (primary): in-repo NIP-01 client (D4) — canonical serialization,
  sha256 id, BIP-340 schnorr sign via coincurve with zero aux randomness
  (deterministic), pure-python reference verification before publish,
  per-relay publish with isolated errors, event id stored as post_ref
- facebook/instagram adapters: exist but refuse to run unless explicitly
  enabled via env (default deployment never touches Meta)
- images: Pillow brand templates (bh gold / hc orange), 1080x1350,
  template-driven typeset design (not AI-generated media)
- failure isolation: config errors audited, item stays approved

tests: 12 new (event roundtrip+tamper, determinism, key validation,
calendar idempotence, approve gate, nostr happy path, future schedule,
clean config failure, disabled meta adapters, image render) — 41 total
2026-10-19 21:54:39 +00:00
cayce 15072a0cd2 Merge PR #3: mail-negotiator
ci / lint-test-leaks (push) Canceled after 0s
IMAP classification (rules + optional LLM), conservative needs_review,
reply drafts gated on explicit human approval, SMTP approved-only,
message-id threading, full audit chain. 29 tests green.
2026-10-19 21:47:29 +00:00
cayce 5b259bc663 mail-negotiator: classify merchant replies, draft gated, audited sends
ci / lint-test-leaks (pull_request) Canceled after 0s
- IMAP poll (UNSEEN, stdlib imaplib) -> parse (bare address via email.utils)
- threading: In-Reply-To -> outbound sent-draft message-id -> proposal
- classify: deterministic rules; optional LLM behind provider interface;
  below-threshold confidence -> needs_review (never guessed)
- unthreadable approvals flagged needs_review (suspicious by definition)
- merchant_status recorded on proposal detail; proposal lifecycle untouched
- reply drafts generated from templates (drafted), NEVER auto-sent
- SMTP sends only draft_status=approved (human approve_draft, audited);
  sent drafts store their Message-ID for future threading
- idempotent on Message-ID; DaemonRun wrapper for monitor

tests: 9 new (classification rules, threshold->review, threading, idempotence,
blocked send without approval, approve->send->audit chain, double approve)
— 29 total passing
2026-10-19 21:47:18 +00:00
cayce 1b2c3cc1cf Merge PR #2: scanner + executor
ci / lint-test-leaks (push) Canceled after 0s
Discovery (read-only) + approval-gated execution with plugin registry,
audited autonomy policy, retry-then-fail. Gate: ruff clean, 20 tests green,
migration smoke ok, no leaks (full history).
2026-10-19 21:42:41 +00:00
cayce 73ccdf7bdf scanner + executor: discovery files proposals; execution gated on approval
ci / lint-test-leaks (pull_request) Canceled after 0s
scanner (read-only):
- sources from CAYCE_OPS_SCANNER_SOURCES JSON (rss + page types), stdlib only
- keyword scoring -> risk_score; kind via heuristics (affiliate/partnership/content_gap)
- idempotent: sha1 dedupe_key; repeated scans skip existing
- every proposal audited (actor daemon:scanner); source errors recorded, never fatal

executor (human gate):
- approve/reject audited with operator identity; only proposed -> approved
- AUTONOMOUS_MODE=true adds exactly one path: policy auto-approval
  (kinds allowlist + daily cap, actor auto:policy) — DECISIONS D5
- plugin registry by kind; unknown kinds fall back to log_action (D9)
- retries with max_attempts, then failed; every transition audited
- http_post plugin inert unless CAYCE_OPS_ENABLE_HTTP_ACTIONS=true

tests: 13 new (dedupe, keyword filter, autonomy bounds, retry-then-fail,
blocked http_post, fallback plugin) — 20 total passing
2026-10-19 21:42:34 +00:00
cayce 00fa3777ab Merge PR #1: skeleton — structure, schema, CI, deploy
ci / lint-test-leaks (push) Canceled after 0s
Component wiring, ORM schema + state machines, env-only config, alembic 0001,
Typer CLI, CI (ruff/pytest/alembic/gitleaks), compose + systemd, DECISIONS D1-D9.
Gate: make check green (ruff 0 errors, 7 tests pass, migration smoke ok, no leaks).
2026-10-19 21:38:06 +00:00
cayce 34a5f331ac skeleton: package, schema, config, CLI, alembic, CI, deploy, docs
ci / lint-test-leaks (pull_request) Canceled after 0s
- cayceops core: env-only config (CAYCE_OPS_*), engine/session, ORM models
  with state machines (proposal/mail/content) and audited writes
- provider-agnostic LLM interface (null provider default, stdlib openai-compat)
- Typer CLI with lazy component loading (scanner/executor/maild/publisher/monitor)
- alembic 0001 initial schema; sqlite-portable JSON columns
- tests: 7 passing on throwaway sqlite; fixtures invented, never real data
- ci: ruff + alembic smoke + pytest + gitleaks(full history) in
  .gitea/workflows + .github/workflows; local gate = make check
- deploy: docker-compose (postgres 16) + hardened systemd units/timers
- docs: DECISIONS D1-D9; README project (grant text retired)
2026-10-19 21:35:50 +00:00
56 changed files with 5738 additions and 60 deletions
+56
View File
@@ -0,0 +1,56 @@
# Cayce Ops — copy to .env and fill in. EVERY value below is a placeholder.
# NEVER commit real credentials. .env is gitignored; gitleaks guards history.
# --- core ---
CAYCE_OPS_DATABASE_URL=postgresql+psycopg://user:password@localhost:5432/cayce_ops
CAYCE_OPS_AUTONOMOUS_MODE=false
CAYCE_OPS_LOG_LEVEL=INFO
CAYCE_OPS_STATE_DIR=./
CAYCE_OPS_MEDIA_DIR=./media
# --- scanner ---
# JSON array of sources: [{"type":"rss","url":"https://example.org/feed.xml"},
# {"type":"page","url":"https://example.org/partners"}]
CAYCE_OPS_SCANNER_SOURCES=[]
CAYCE_OPS_SCANNER_KEYWORDS=affiliate,partnership,bitcoin,lightning,travel
CAYCE_OPS_SCANNER_USER_AGENT=cayce-ops-scanner/0.1
# --- executor ---
# with AUTONOMOUS_MODE=true: which proposal kinds may be auto-approved, daily cap
CAYCE_OPS_AUTONOMOUS_KINDS=
CAYCE_OPS_AUTONOMOUS_DAILY_CAP=0
CAYCE_OPS_MAX_ATTEMPTS=2
CAYCE_OPS_ENABLE_HTTP_ACTIONS=false
# when set (64-char hex x-only pubkey), executor approve REQUIRES a BIP-340
# signature over sha256("cayce-ops:approve:<id>") made by the operator key
CAYCE_OPS_OPERATOR_PUBKEY=
# --- mail (IMAP read / SMTP send-approved-drafts-only) ---
CAYCE_OPS_IMAP_HOST=imap.example.org
CAYCE_OPS_IMAP_PORT=993
CAYCE_OPS_IMAP_USER=agent@example.org
CAYCE_OPS_IMAP_PASSWORD=change-me
CAYCE_OPS_IMAP_MAILBOX=INBOX
CAYCE_OPS_SMTP_HOST=smtp.example.org
CAYCE_OPS_SMTP_PORT=587
CAYCE_OPS_SMTP_USER=agent@example.org
CAYCE_OPS_SMTP_PASSWORD=change-me
CAYCE_OPS_MAIL_FROM=agent@example.org
# --- llm (null = no LLM; components fall back to rules) ---
CAYCE_OPS_LLM_PROVIDER=null
CAYCE_OPS_LLM_MODEL=
CAYCE_OPS_LLM_BASE_URL=https://api.openai.com/v1
CAYCE_OPS_LLM_API_KEY=
CAYCE_OPS_LLM_REVIEW_THRESHOLD=0.7
# --- publisher / nostr (key: 32-byte hex; relays: wss:// comma list) ---
CAYCE_OPS_NOSTR_PRIVATE_KEY=
CAYCE_OPS_NOSTR_RELAYS=wss://relay.example.org,wss://other.relay.example.org
CAYCE_OPS_ENABLE_FACEBOOK=false
CAYCE_OPS_FACEBOOK_PAGE_TOKEN=
CAYCE_OPS_ENABLE_INSTAGRAM=false
CAYCE_OPS_INSTAGRAM_USER_ID=
# --- monitor ---
CAYCE_OPS_MONITOR_OUTPUT=./status.html
+35
View File
@@ -0,0 +1,35 @@
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
lint-test-leaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # gitleaks needs full history
- uses: astral-sh/setup-uv@v5
with:
python-version: "3.12"
- name: Install (locked)
run: uv sync --frozen
- name: Lint (ruff)
run: uv run ruff check .
- name: Migrations smoke test (SQLite)
run: uv run alembic upgrade head
- name: Tests (pytest)
run: uv run pytest
- name: Secret scan (gitleaks, full history)
run: |
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz | tar -xz gitleaks
./gitleaks detect --source . --log-opts="--all" -v
+35
View File
@@ -0,0 +1,35 @@
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
lint-test-leaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # gitleaks needs full history
- uses: astral-sh/setup-uv@v5
with:
python-version: "3.12"
- name: Install (locked)
run: uv sync --frozen
- name: Lint (ruff)
run: uv run ruff check .
- name: Migrations smoke test (SQLite)
run: uv run alembic upgrade head
- name: Tests (pytest)
run: uv run pytest
- name: Secret scan (gitleaks, full history)
run: |
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz | tar -xz gitleaks
./gitleaks detect --source . --log-opts="--all" -v
+24
View File
@@ -0,0 +1,24 @@
# --- secrets / env ---
.env
.env.*
!.env.example
# --- python ---
__pycache__/
*.py[cod]
.venv/
*.egg-info/
.pytest_cache/
.ruff_cache/
dist/
# --- runtime artifacts ---
*.sqlite3
*.db
status.html
media/
node_modules/
uv.lock.bak
# --- OS ---
.DS_Store
+8
View File
@@ -0,0 +1,8 @@
# gitleaks configuration
# tests/ contains invented fixtures (fake Nostr key, fake merchant mail) — allowlisted
# so the full-history scan stays signal-only. See docs/DECISIONS.md D7.
[allowlist]
paths = [
'''(^|/)tests/''',
]
+1
View File
@@ -0,0 +1 @@
3.12
+157
View File
@@ -0,0 +1,157 @@
# AUDIT — cayce-ops
Audited commit: **d500d88** — merge of PR #7 on `main`.
> Note for the auditor: PRs #1-#6 merged with E501 lint findings present
> (per-branch gate runs were misread). PR #7 is a lint-only cleanup
> (line wrapping, F841/F821/E731 fixes, no behavior change) that restores
> a fully green gate. The honest trail is kept here on purpose.
> Re-run `make check` at d500d88 to verify: ruff 0 · 44 tests · no leaks.
Gate that commit passed: `make check` — ruff (0 findings), 44 tests green,
alembic smoke on sqlite, gitleaks full history (no leaks).
## 1. Component map and data flow
```
CAYCE_OPS_SCANNER_SOURCES (external URLs)
│ read-only fetch
▼
┌──────────┐ proposals(status=proposed) ┌──────────┐
│ scanner │─────────────────────────────▶│ DB │
└──────────┘ │ (shared) │
│ │ audit_log (every write)
┌──────────┐ approved → running → │ │◀────── actor / action / detail /
│ executor │◀──── done|failed ───────────│ │ result on every transition
└──────────┘ plugins by kind └──────────┘
│
┌──────────┐ IMAP inbox ───────────────▶│ mail_messages,
│ maild │ drafts (gated) │ proposals(detail)
└──────────┘ SMTP only draft=approved │
│
┌──────────┐ content_items ────────────▶│ │──▶ relay (nostr) / Meta (opt-in)
│publisher │ draft→approved→published └──────────┘
└──────────┘
┌──────────┐ reads daemon_runs, audit_log, counts
│ monitor │────────────────────────────────────▶ status.html (static)
└──────────┘
```
Shared schema (`cayceops/models.py`, migration `alembic/versions/0001_initial.py`):
`proposals`, `audit_log`, `mail_messages`, `content_items`, `daemon_runs`.
## 2. Every external call (where, what it sends, what triggers it)
| # | Call | Code | Sends | Trigger |
|---|------|------|-------|---------|
| 1 | HTTP GET to scanner sources | `cayceops/components/scanner/scanner.py` → `_fetch` (urllib) | nothing but the request (+ `CAYCE_OPS_SCANNER_USER_AGENT` header) to URLs **configured by the operator** | `cayceops scanner run` (cron/systemd timer) |
| 2 | IMAP fetch (IMAP4_SSL, UNSEEN) | `cayceops/components/maild/maild.py` → `connect_imap`, `_fetch_unread` | credentials from env; reads inbox | `cayceops maild poll` |
| 3 | SMTP send | `maild.py` → `_smtp_send` (smtplib, STARTTLS) | a reply **draft a human approved** to the original sender's bare address | `cayceops maild send-approved`, and only rows with `draft_status=approved` |
| 4 | LLM chat completion (openai-compatible) | `cayceops/llm.py` → `OpenAICompatProvider.complete` (urllib) | the email text (truncated 4000 chars) + system prompt; `Authorization: Bearer <env key>` | `maild poll` **only if** `CAYCE_OPS_LLM_PROVIDER != null` (default is null → never) |
| 5 | Nostr relay publish (websocket) | `cayceops/components/publisher/nostr.py` → `relay_publish`/`publish` | a signed NIP-01 event whose content is the **human-approved** caption | `cayceops publisher publish-due`, channel=nostr, only if key+relays configured |
| 6 | Meta Graph API | `cayceops/components/publisher/adapters.py` | caption (+optional hosted image URL) + page token | **refuses to run** unless `CAYCE_OPS_ENABLE_FACEBOOK/INSTAGRAM=true` (default false). Currently unreachable from `publish_due`. |
No other network code exists. Grep guards: `urllib.request`, `imaplib`, `smtplib`,
`websockets` appear only in the files above (verified by review; CI could add a
lint rule — see *Known risks*).
## 3. What each component can do without a human / with AUTONOMOUS_MODE=true
| Component | Without any human (default) | With `CAYCE_OPS_AUTONOMOUS_MODE=true` |
|---|---|---|
| scanner | fetch sources, file proposals, write audit | identical (scanner never executes) |
| executor | nothing executes by itself; it acts only on rows a human set to `approved` (via `executor approve --actor …`) | **one added path**: auto-approves `proposed` items whose `kind` ∈ `CAYCE_OPS_AUTONOMOUS_KINDS`, capped at `CAYCE_OPS_AUTONOMOUS_DAILY_CAP`/day, actor `auto:policy`, fully audited. Then executes those. |
| maild | polls IMAP, classifies, writes drafts. **Never sends.** | identical — autonomy does not touch mail |
| publisher | plans drafts; publishes nothing | identical — publishing always requires `publisher approve --actor …`; autonomy has no effect |
| monitor | reads and renders | identical |
Explicitly impossible in the current code: sending an unapproved mail draft,
publishing unapproved content, executing a non-approved proposal (the single
exception is the audited `auto:policy` path above), disabling the audit write.
## 4. Environment variables and permissions per component
All config is env-only (`cayceops/config.py`), prefix `CAYCE_OPS_`. Full list
with placeholders: `.env.example`.
| Component | Requires | Notes |
|---|---|---|
| core | `DATABASE_URL` (postgres or sqlite), `LOG_LEVEL`, `STATE_DIR`, `MEDIA_DIR` | DB user needs SELECT/INSERT/UPDATE on the five tables |
| scanner | `SCANNER_SOURCES` (JSON), `SCANNER_KEYWORDS`, `SCANNER_USER_AGENT` | outbound GET only |
| executor | `AUTONOMOUS_MODE`, `AUTONOMOUS_KINDS`, `AUTONOMOUS_DAILY_CAP`, `MAX_ATTEMPTS`, `ENABLE_HTTP_ACTIONS` | `http_post` plugin stays inert unless enabled |
| maild | `IMAP_HOST/PORT/USER/PASSWORD/MAILBOX`, `SMTP_HOST/PORT/USER/PASSWORD`, `MAIL_FROM`, `LLM_PROVIDER/MODEL/BASE_URL/API_KEY/REVIEW_THRESHOLD` | IMAP user is read+mark-seen; SMTP login can send only as `MAIL_FROM` |
| publisher | `NOSTR_PRIVATE_KEY` (32-byte hex), `NOSTR_RELAYS`, `ENABLE_FACEBOOK`, `FACEBOOK_PAGE_TOKEN`, `ENABLE_INSTAGRAM`, `INSTAGRAM_USER_ID` | nostr key = signing authority; treat as secret |
| monitor | `MONITOR_OUTPUT` (writable path) | needs read on DB, write on output only |
Filesystem permissions: default deployment is the `cayce` user owning
`/srv/cayce-ops` with systemd hardening (`NoNewPrivileges`, `ProtectSystem=strict`,
`ProtectHome=yes`) — see `deploy/systemd/cayce-ops@.service`.
## 5. Dependencies (pinned in `pyproject.toml` + `uv.lock`)
| Package | Version | Why |
|---|---|---|
| sqlalchemy | 2.0.44 | ORM + engine for the shared schema |
| alembic | 1.17.0 | production migrations |
| typer | 0.20.0 | CLI (all five daemons are CLI commands) |
| psycopg[binary] | 3.2.13 | Postgres driver (production stack) |
| pillow | 12.0.0 | brand image templates (publisher) |
| coincurve | 21.0.0 | libsecp256k1 binding: NIP-01 event signing (publisher) |
| websockets | 15.0.1 | nostr relay publish (sync client) |
| pytest | 9.0.2 (dev) | test suite |
| ruff | 0.15.1 (dev) | lint |
stdlib doing heavy lifting: `imaplib`/`smtplib`/`email` (maild), `urllib` (scanner,
LLM, Meta adapters), `xml.etree`/`html.parser` (scanner), `hashlib`/`json` (nostr ids).
## 6. Known risks and open items
1. **No live Gitea Actions runner on the current host** (DECISIONS D6). CI files
ship for GitHub/Gitea Actions, and the same gate runs locally pre-push
(`scripts/pre-push`), but until a runner exists there is no third-party
enforcement of the gate on every push.
2. **`auto:policy` path is real autonomy** — bounded (kind allowlist + daily cap +
audit) but it does execute without a human in the loop when enabled. The cap
counter is DB-based; wiping `audit_log` resets it.Mitigation: only the
operator can set the env flags, and the audit log is append-only by convention.
3. **Executor plugins are in-process Python** (D9): no sandboxing. A malicious
plugin = arbitrary code. Mitigation: plugins are repo code, reviewed in PRs;
the registry starts with `log_action` fallbacks only.
4. **Mail classification is heuristic** (rules; LLM optional). Conservative by
design (low confidence → `needs_review`, unthreaded approvals → review), but
a sophisticated phishing email could still be misclassified `other`. Human
gate on sends bounds the damage to drafting wrong replies (never sending them).
5. **Nostr relays are plaintext-config endpoints**: a hostile relay sees the
note content (public by design) and could replay it; event ids make duplication
detectable. No NIP-05, no retries across relay pools yet.
6. **`detail` JSON columns are schemaless** — writers evolve payloads without
migrations. Monitor intentionally renders none of it.
7. **gitleaks allowlist covers `tests/`** (D7): a real secret pasted under
`tests/` would not trip the scanner. Compensating control: PR review; no
fixture reads the environment.
8. **Open:** provision an act_runner for Gitea Actions; wire `posts_log`-style
engagement back into publisher planning; add NIP-05 identifier; retries for
relay publish.
## 7. Running tests and the local stack
```bash
uv sync --frozen # python 3.12 + locked deps
uv run pytest # 44 tests, sqlite, no network
make check # lint + tests + alembic smoke + gitleaks (pre-push gate)
# full local stack (five daemons + postgres 16):
cp .env.example .env # fill placeholders; nothing real required for db-only
docker compose up -d db
uv run alembic upgrade head
docker compose up -d # or run each: uv run cayceops {scanner,executor,maild,publisher,monitor} …
# production path (VPS):
# see deploy/systemd/README.md (hardened units + timers)
```
## Audited commit
- Repository: `cayce/cayce-ops` (branch `main`)
- **Audited: d500d888701c3f1cd3305d098a448c3e20a8ad99** (merge of PR #7)
- Gate at that commit: ruff clean (0 findings) · 44/44 tests · alembic 0001
smoke OK · gitleaks `--all` no leaks · `make check` exit 0.
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
# uv for fast locked installs
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY cayceops/ cayceops/
COPY alembic/ alembic/
COPY alembic.ini .
ENV PATH="/app/.venv/bin:$PATH"
ENTRYPOINT ["cayceops"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 cayce-ops contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+25
View File
@@ -0,0 +1,25 @@
.PHONY: check lint test migrate-check leaks sync hooks
sync: ## install locked deps (creates .venv)
uv sync --frozen
lint: ## ruff
uv run ruff check .
test: ## pytest
uv run pytest
migrate-check: ## smoke the alembic chain on a scratch sqlite db
rm -f /tmp/cayce-ops-migrate.db
CAYCE_OPS_DATABASE_URL=sqlite:////tmp/cayce-ops-migrate.db uv run alembic upgrade head
@rm -f /tmp/cayce-ops-migrate.db
leaks: ## gitleaks over the full history
gitleaks detect --source . --log-opts="--all" -v
hooks: ## install the pre-push gate as a git hook (part of setup)
git config core.hooksPath scripts/githooks
@echo "pre-push gate installed (scripts/githooks/pre-push)"
check: lint test migrate-check leaks ## the pre-push gate — must be green before every push
@echo "check: OK"
+55 -60
View File
@@ -1,75 +1,70 @@
# Cayce — OpenSats Grant Proposal
# Cayce is an agent-operated project run by Gustavo.
> Autonomous entity. Próspera ZEDE. My agents dredge the noise; I bring you the signal.
> I run [hardcap.news](https://hardcap.news) and [bitcoinhotels.org](https://bitcoinhotels.org) — brutally honest about all of it.
> [cayce-co.xyz](https://cayce-co.xyz)
Cayce Ops is the autonomous operations stack behind Cayce — an agent that runs
[bitcoinhotels.org](https://bitcoinhotels.org) (Bitcoin-native travel guides)
and [hardcap.news](https://hardcap.news) (Bitcoin security intelligence).
---
Five decoupled components share one Postgres database. Each is a CLI command,
driven by cron/systemd timers. Nothing runs without an audit trail.
## Who / What is Cayce
Cayce is an **autonomous software entity** — not a person, not a company. It operates
a small fleet of daemons on its own infrastructure, makes its own decisions within a
public mandate, and maintains two open, Bitcoin-native web properties:
| Property | What it does | Status |
| Component | What it does | Human gate |
|---|---|---|
| **bitcoinhotels.org** | Bitcoin-native travel directory: hotels, destinations, field-tested guides (paying with Lightning abroad, ATM networks by city, living on sats in El Salvador) | Live, self-hosted |
| **hardcap.news** | Bitcoin security intelligence: the Travel Security Kit, wrench-attack dataset analysis, border opsec | Live, self-hosted |
| **scanner** | Discovers opportunities (affiliates, partnerships, content gaps) from configured sources; files them as `proposals` | none — it only proposes |
| **executor** | Runs `approved` proposals through registered action plugins; full audit of who approved, what ran, what happened | approval required (`AUTONOMOUS_MODE=false` by default) |
| **mail-negotiator** | Classifies merchant replies (approval / rejection / question / other) over IMAP; drafts replies; SMTP sends **only human-approved drafts** | draft approval required |
| **publisher** | Themed content calendar. Nostr (NIP-01) adapter first; Facebook/Instagram adapters exist but are **disabled by default**; brand images via Pillow templates | publish requires approved items |
| **monitor** | Static status page: daemon health, last runs, proposal queue, recent errors. No sensitive data | none — read-only |
Cayce also maintains its own operational stack: a Postgres-backed opportunity scanner,
an initiative executor, an email daemon that negotiates with merchants autonomously,
and a social media presence it runs end-to-end (Facebook + Instagram + Nostr).
State machines:
## What the grant would fund
```
proposal: proposed -> approved -> running -> done | failed
mail: inbound classified; draft: none -> drafted -> approved -> sent
content: draft -> approved -> published
```
### 1. Open-sourcing the autonomous operations stack
The daemons that make Cayce autonomous — the scanner, the executor, the mail negotiator,
the social publisher — are written, tested, and running in production today. The grant
funds cleaning, documenting, and publishing them as reusable open-source components:
## Quickstart (local)
- **scanner** — discovers opportunities (affiliates, partnerships, content gaps) and files proposals to a database
- **executor** — executes approved initiatives without human intervention
- **mail-negotiator** — IMAP/SMTP daemon that parses merchant approvals/rejections and acts on them
- **publisher** — themed content calendar engine (FB/IG/Nostr) with brand image generation
- **monitor** — single-pane status page for the whole organism
```bash
uv sync --frozen # installs Python 3.12 + locked deps
cp .env.example .env # fill in; values are placeholders
docker compose up -d db # Postgres 16
uv run alembic upgrade head
uv run cayceops scanner run
uv run pytest
```
### 2. The Bitcoin Travel Data Commons
Every guide on bitcoinhotels.org is built from field-collected data: ATM operators and
their fee structures by city, no-KYC caps, Lightning acceptance rates, border policies.
The grant funds turning this into an **open, structured dataset** any Bitcoin traveler
(or any other project) can consume — CC-licensed, machine-readable, community-updatable.
`docker compose up` (everything) runs all five daemons in loops; on a VPS use
[deploy/systemd](deploy/systemd/README.md) instead.
### 3. The Travel Security Kit as an open project
hardcap.news's Travel Security Kit — seed strategies that survive a border search, the
gift-card rescue rail, device choice doctrine — becomes an open, versioned document
with community review, translated to Spanish and Portuguese.
## Layout
## Why an entity, not a company
```
cayceops/ the package
config.py env-var config (CAYCE_OPS_*), no config files
db.py engine/session/upgrade helpers
models.py ORM schema + state machines + audit helper
llm.py provider-agnostic LLM interface (null by default)
components/
scanner/ discovery -> proposals (never executes)
executor/ plugin execution + audit
maild/ IMAP classify, drafts, SMTP gated on approval
publisher/ calendar, nostr adapter, image templates
monitor/ static status page builder
alembic/ migrations (production schema path)
tests/ pytest suite (invented fixtures only)
deploy/systemd/ VPS units + timers
docs/DECISIONS.md architectural decisions and their costs
```
OpenSats funds people and projects building the Bitcoin commons. Cayce is neither a
person nor a company — it is an autonomous entity with a public mandate, operating
transparently, with no shareholders, no owners, and nothing to extract. Its output is
infrastructure: guides, datasets, and code that make living on Bitcoin easier and safer.
## Security posture
Cayce never denies being AI. The work stands on its own.
- Config exclusively via env vars; `.env` is gitignored, `.env.example` holds placeholders.
- `make check` (and CI): ruff, pytest, alembic smoke, **gitleaks over the full history**.
- SMTP send, social publish, and proposal execution all require a human action
recorded in the audit log — unless `AUTONOMOUS_MODE=true`, which enables one
narrow, capped, audited exception (see [docs/DECISIONS.md](docs/DECISIONS.md) D5).
## Metrics of the first 12 months
## License
- 3 field-tested travel guides published (all indexed, all organic traffic)
- Travel Security Kit published and linked from the Bitcoin community
- 22 approved initiatives in the autonomous pipeline
- Social presence operated end-to-end by the entity (FB + IG + Nostr)
- 100% uptime on both properties, self-hosted on owned infrastructure
## Contact
- Web: https://cayce-co.xyz
- Nostr: (entity npub — see cayce-co.xyz)
- Fediverse: via ActivityPub on this instance
- Email: cayce@cayce-co.xyz
---
*License: all code and content in Cayce's repositories is open source / open content
(MIT / CC-BY-SA) unless stated otherwise.*
MIT — see [LICENSE](LICENSE).
+37
View File
@@ -0,0 +1,37 @@
[alembic]
script_location = alembic
; sqlalchemy.url is overridden at runtime from CAYCE_OPS_DATABASE_URL (db.py)
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+59
View File
@@ -0,0 +1,59 @@
"""Alembic environment — URL injected at runtime from CAYCE_OPS_DATABASE_URL."""
from __future__ import annotations
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from cayceops.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
if not config.get_main_option("sqlalchemy.url"):
import os
config.set_main_option(
"sqlalchemy.url",
os.environ.get("CAYCE_OPS_DATABASE_URL", "sqlite:///cayce-ops.db"),
)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True, # required for SQLite ALTER support
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+144
View File
@@ -0,0 +1,144 @@
"""initial schema: proposals, audit_log, mail_messages, content_items, daemon_runs
Revision ID: 0001
Revises:
Create Date: 2026-10-19
"""
import sqlalchemy as sa
from alembic import op
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"proposals",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"source",
sa.Enum("SCANNER", "MANUAL", name="proposalsource"),
nullable=False,
),
sa.Column("kind", sa.String(length=64), nullable=False),
sa.Column("title", sa.String(length=512), nullable=False),
sa.Column("url", sa.String(length=1024), nullable=True),
sa.Column("detail", sa.JSON(), nullable=False),
sa.Column("risk_score", sa.Float(), nullable=False),
sa.Column(
"status",
sa.Enum(
"PROPOSED", "APPROVED", "REJECTED", "RUNNING", "DONE", "FAILED",
name="proposalstatus",
),
nullable=False,
),
sa.Column("attempts", sa.Integer(), nullable=False),
sa.Column("dedupe_key", sa.String(length=256), nullable=True),
sa.Column("approved_by", sa.String(length=128), nullable=True),
sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("dedupe_key"),
)
op.create_index("ix_proposals_status", "proposals", ["status"])
op.create_table(
"audit_log",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("ts", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor", sa.String(length=128), nullable=False),
sa.Column("proposal_id", sa.Integer(), nullable=True),
sa.Column("action", sa.String(length=64), nullable=False),
sa.Column("detail", sa.JSON(), nullable=False),
sa.Column("result", sa.String(length=32), nullable=False),
sa.ForeignKeyConstraint(["proposal_id"], ["proposals.id"], ondelete="SET NULL"),
)
op.create_index("ix_audit_log_ts", "audit_log", ["ts"])
op.create_index("ix_audit_log_proposal_id", "audit_log", ["proposal_id"])
op.create_table(
"mail_messages",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"direction",
sa.Enum("INBOUND", "OUTBOUND", name="maildirection"),
nullable=False,
),
sa.Column("message_id", sa.String(length=998), nullable=True),
sa.Column("in_reply_to", sa.String(length=998), nullable=True),
sa.Column("from_addr", sa.String(length=320), nullable=True),
sa.Column("to_addr", sa.String(length=320), nullable=True),
sa.Column("subject", sa.String(length=998), nullable=True),
sa.Column("body", sa.Text(), nullable=True),
sa.Column(
"classification",
sa.Enum(
"APPROVAL", "REJECTION", "QUESTION", "OTHER", "NEEDS_REVIEW",
name="mailclassification",
),
nullable=True,
),
sa.Column("confidence", sa.Float(), nullable=False),
sa.Column("proposal_id", sa.Integer(), nullable=True),
sa.Column(
"draft_status",
sa.Enum(
"NONE", "DRAFTED", "APPROVED", "SENT", "SKIPPED", name="draftstatus"
),
nullable=False,
),
sa.Column("draft_reply", sa.Text(), nullable=True),
sa.Column("processed_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["proposal_id"], ["proposals.id"], ondelete="SET NULL"),
)
op.create_index("ix_mail_messages_message_id", "mail_messages", ["message_id"])
op.create_index("ix_mail_messages_proposal_id", "mail_messages", ["proposal_id"])
op.create_table(
"content_items",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("theme", sa.String(length=128), nullable=False),
sa.Column("channel", sa.String(length=32), nullable=False),
sa.Column("caption", sa.Text(), nullable=False),
sa.Column("media_path", sa.String(length=1024), nullable=True),
sa.Column("link", sa.String(length=1024), nullable=True),
sa.Column(
"status",
sa.Enum("DRAFT", "APPROVED", "PUBLISHED", name="contentstatus"),
nullable=False,
),
sa.Column("scheduled_for", sa.DateTime(timezone=True), nullable=True),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("post_ref", sa.String(length=512), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_content_items_status", "content_items", ["status"])
op.create_table(
"daemon_runs",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("daemon", sa.String(length=32), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("detail", sa.JSON(), nullable=False),
)
op.create_index("ix_daemon_runs_daemon", "daemon_runs", ["daemon"])
def downgrade() -> None:
op.drop_table("daemon_runs")
op.drop_table("content_items")
op.drop_table("mail_messages")
op.drop_table("audit_log")
op.drop_table("proposals")
for enum_name in (
"proposalsource", "proposalstatus", "maildirection",
"mailclassification", "draftstatus", "contentstatus",
):
op.execute(f"DROP TYPE IF EXISTS {enum_name}")
+68
View File
@@ -0,0 +1,68 @@
"""audit hardening migration — minimal standalone mirror of 0002.
Revision ID: 0002
Revises: 0001
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0002"
down_revision = "0001"
branch_labels = None
depends_on = None
OLD_CONTENT_STATUSES = ("DRAFT", "APPROVED", "PUBLISHED")
NEW_CONTENT_STATUSES = ("DRAFT", "APPROVED", "PUBLISHED", "SKIPPED")
def upgrade() -> None:
with op.batch_alter_table("audit_log") as batch:
batch.add_column(sa.Column("prev_hash", sa.String(length=64), nullable=True))
batch.add_column(sa.Column("entry_hash", sa.String(length=64), nullable=True))
batch.create_index("ix_audit_log_entry_hash", ["entry_hash"])
with op.batch_alter_table("proposals") as batch:
batch.alter_column("risk_score", new_column_name="match_score")
with op.batch_alter_table("mail_messages") as batch:
batch.add_column(sa.Column("approved_hash", sa.String(length=64), nullable=True))
batch.add_column(sa.Column("approved_by", sa.String(length=128), nullable=True))
if op.get_bind().dialect.name == "postgresql":
op.execute("ALTER TYPE contentstatus ADD VALUE IF NOT EXISTS 'SKIPPED'")
with op.batch_alter_table("content_items") as batch:
batch.alter_column(
"status",
existing_type=sa.Enum(*OLD_CONTENT_STATUSES, name="contentstatus"),
type_=sa.Enum(*NEW_CONTENT_STATUSES, name="contentstatus"),
existing_nullable=False,
)
batch.add_column(sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"))
batch.add_column(sa.Column("approved_hash", sa.String(length=64), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("content_items") as batch:
batch.drop_column("approved_hash")
batch.drop_column("attempts")
batch.alter_column(
"status",
existing_type=sa.Enum(*NEW_CONTENT_STATUSES, name="contentstatus"),
type_=sa.Enum(*OLD_CONTENT_STATUSES, name="contentstatus"),
existing_nullable=False,
)
with op.batch_alter_table("mail_messages") as batch:
batch.drop_column("approved_by")
batch.drop_column("approved_hash")
with op.batch_alter_table("proposals") as batch:
batch.alter_column("match_score", new_column_name="risk_score")
with op.batch_alter_table("audit_log") as batch:
batch.drop_index("ix_audit_log_entry_hash")
batch.drop_column("entry_hash")
batch.drop_column("prev_hash")
+3
View File
@@ -0,0 +1,3 @@
"""Cayce Ops — autonomous operations stack."""
__version__ = "0.1.0"
+195
View File
@@ -0,0 +1,195 @@
"""Cayce Ops CLI (Typer).
Component sub-apps are imported lazily so the skeleton CLI works before every
component module lands, and so one broken component can't take down the CLI.
"""
from __future__ import annotations
import typer
from cayceops import __version__
app = typer.Typer(name="cayceops", help="Cayce autonomous operations stack", no_args_is_help=True)
db_app = typer.Typer(name="db", help="Schema lifecycle")
app.add_typer(db_app)
@db_app.command("init")
def db_init() -> None:
"""Create all tables from ORM metadata (dev/test)."""
from cayceops.db import init_db
init_db()
typer.echo("schema created (metadata.create_all)")
@db_app.command("upgrade")
def db_upgrade() -> None:
"""Run Alembic migrations to head (production path)."""
from cayceops.db import upgrade_db
upgrade_db()
typer.echo("migrations applied")
@app.command()
def version() -> None:
"""Print the version."""
typer.echo(f"cayce-ops {__version__}")
def _load_component(name: str):
"""Import a component module by name (lazy)."""
import importlib
return importlib.import_module(f"cayceops.components.{name}")
# --- scanner -----------------------------------------------------------------
scanner_app = typer.Typer(name="scanner", help="Opportunity discovery (read-only)")
app.add_typer(scanner_app)
@scanner_app.command("run")
def scanner_run() -> None:
"""Scan configured sources and file proposals. Never executes anything."""
raise typer.Exit(_load_component("scanner").run())
# --- executor ----------------------------------------------------------------
executor_app = typer.Typer(name="executor", help="Execute approved proposals")
app.add_typer(executor_app)
@executor_app.command("run")
def executor_run() -> None:
"""Process approved proposals through registered action plugins."""
raise typer.Exit(_load_component("executor").run())
@executor_app.command("approve")
def executor_approve(
proposal_id: int,
actor: str = typer.Option(..., help="Human approver id"),
signature: str = typer.Option(
None, help="BIP-340 sig (required when operator pubkey is set)"
),
) -> None:
"""Approve a proposal (human action, audited)."""
raise typer.Exit(
_load_component("executor").approve(proposal_id, actor, signature=signature)
)
@executor_app.command("reject")
def executor_reject(proposal_id: int, actor: str = typer.Option(...)) -> None:
"""Reject a proposal (human action, audited)."""
raise typer.Exit(_load_component("executor").reject(proposal_id, actor))
# --- maild -------------------------------------------------------------------
maild_app = typer.Typer(name="maild", help="Merchant mail negotiation")
app.add_typer(maild_app)
@maild_app.command("poll")
def maild_poll() -> None:
"""Poll IMAP, classify inbound mail, draft replies."""
raise typer.Exit(_load_component("maild").poll())
@maild_app.command("approve-draft")
def maild_approve(msg_id: int, actor: str = typer.Option(...)) -> None:
"""Approve a drafted reply so SMTP may send it."""
raise typer.Exit(_load_component("maild").approve_draft(msg_id, actor))
@maild_app.command("send-approved")
def maild_send() -> None:
"""Send all approved drafts via SMTP (per-message commit)."""
raise typer.Exit(_load_component("maild").send_approved())
@maild_app.command("compose")
def maild_compose(
proposal_id: int,
to_addr: str = typer.Option(...),
subject: str = typer.Option(...),
body: str = typer.Option(...),
) -> None:
"""Create the FIRST-CONTACT outbound draft for a proposal (drafted)."""
raise typer.Exit(
_load_component("maild").compose(proposal_id, to_addr, subject, body)
)
@maild_app.command("run")
def maild_run() -> None:
"""One daemon cycle: poll inbound, then send approved drafts."""
comp = _load_component("maild")
code = comp.poll()
if code == 0:
code = comp.send_approved()
raise typer.Exit(code)
# --- publisher ---------------------------------------------------------------
publisher_app = typer.Typer(name="publisher", help="Themed content calendar")
app.add_typer(publisher_app)
@publisher_app.command("plan")
def publisher_plan() -> None:
"""Fill the calendar with drafts for the coming days (per theme rotation)."""
raise typer.Exit(_load_component("publisher").plan())
@publisher_app.command("image")
def publisher_image(item_id: int) -> None:
"""Render the brand image for a content item."""
raise typer.Exit(_load_component("publisher").make_image(item_id))
@publisher_app.command("approve")
def publisher_approve(item_id: int, actor: str = typer.Option(...)) -> None:
"""Approve a draft content item."""
raise typer.Exit(_load_component("publisher").approve(item_id, actor))
@publisher_app.command("publish-due")
def publisher_publish() -> None:
"""Publish approved content items that are due."""
raise typer.Exit(_load_component("publisher").publish_due())
@publisher_app.command("run")
def publisher_run() -> None:
"""One daemon cycle: publish due approved items (plan is cron'd separately)."""
raise typer.Exit(_load_component("publisher").publish_due())
# --- monitor -----------------------------------------------------------------
monitor_app = typer.Typer(name="monitor", help="Static status page")
app.add_typer(monitor_app)
@monitor_app.command("build")
def monitor_build() -> None:
"""Render the static status page (no sensitive data)."""
raise typer.Exit(_load_component("monitor").build())
@monitor_app.command("run")
def monitor_run() -> None:
"""One daemon cycle: build the status page."""
raise typer.Exit(_load_component("monitor").build())
def main() -> None: # pragma: no cover
app()
if __name__ == "__main__": # pragma: no cover
main()
+1
View File
@@ -0,0 +1 @@
"""Skeleton placeholder packages — one module per component lands in its own PR."""
+1
View File
@@ -0,0 +1 @@
"""executor package."""
+299
View File
@@ -0,0 +1,299 @@
"""executor — executes APPROVED proposals through registered action plugins.
Guarantees
----------
- Only ``approved`` proposals execute (``AUTONOMOUS_MODE=false`` default).
- With ``AUTONOMOUS_MODE=true`` the executor gains exactly one capability:
bounded auto-approval of ``proposed`` items whose ``kind`` is in
``CAYCE_OPS_AUTONOMOUS_KINDS``, capped by ``CAYCE_OPS_AUTONOMOUS_DAILY_CAP``,
recorded with actor ``auto:policy`` (DECISIONS D5).
- Every transition writes an ``audit_log`` row: who approved, what ran,
plugin output, success/failure.
- Unknown proposal kinds fall back to the ``log_action`` plugin (never a
network side effect). See DECISIONS D9.
"""
from __future__ import annotations
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func, select
from cayceops.components.executor import plugins as plugin_registry
from cayceops.config import Config
from cayceops.db import session_scope
from cayceops.models import AuditEntry, DaemonRun, Proposal, ProposalStatus, audit
Actor = str
def _load_cfg():
from cayceops.config import load
return load()
# --- human transitions -------------------------------------------------------
# message signed by the operator key when CAYCE_OPS_OPERATOR_PUBKEY is set
def approval_message(proposal_id: int) -> bytes:
import hashlib
return hashlib.sha256(f"cayce-ops:approve:{proposal_id}".encode()).digest()
def approve(
proposal_id: int,
actor: Actor,
cfg: Config | None = None,
sess=None,
signature: str | None = None,
) -> int:
"""Human approves a proposal. Audited with the operator's identity.
When CAYCE_OPS_OPERATOR_PUBKEY is configured, `signature` (hex, BIP-340
over approval_message(id)) is REQUIRED and verified against that key —
'actor' then is a label, the key is the proof (audit finding 1).
"""
from cayceops.components.publisher.nostr import verify_schnorr
cfg = cfg or _load_cfg()
if cfg.operator_pubkey:
ok = bool(signature) and verify_schnorr(
signature, approval_message(proposal_id), cfg.operator_pubkey
)
if not ok and sess is not None:
audit(
sess,
actor=actor,
action="approve",
proposal_id=proposal_id,
result="error",
detail={
"reason": (
"operator pubkey configured: signature required"
if not signature
else "invalid approval signature"
)
},
)
if not ok:
return 2
return _decide(proposal_id, actor, ProposalStatus.APPROVED, cfg, sess, signature)
def reject(proposal_id: int, actor: Actor, cfg: Config | None = None, sess=None) -> int:
"""Human rejects a proposal. Audited with the operator's identity."""
return _decide(proposal_id, actor, ProposalStatus.REJECTED, cfg, sess)
def _decide(
proposal_id: int,
actor: Actor,
decision: ProposalStatus,
cfg: Config | None,
sess,
signature: str | None = None,
) -> int:
owned = False
if sess is None:
outer = session_scope()
sess = outer.__enter__()
owned = True
try:
proposal = sess.get(Proposal, proposal_id)
if proposal is None:
return 1
if proposal.status != ProposalStatus.PROPOSED:
verb = "approve" if decision == ProposalStatus.APPROVED else "reject"
audit(
sess,
actor=actor,
action=verb,
proposal_id=proposal_id,
result="error",
detail={"reason": f"invalid state: {proposal.status.value}"},
)
return 1
proposal.status = decision
detail: dict = {}
if decision == ProposalStatus.APPROVED:
proposal.approved_by = actor
proposal.approved_at = datetime.now(UTC)
if signature:
detail["signature"] = signature[:32] + "..."
verb = "approve" if decision == ProposalStatus.APPROVED else "reject"
audit(sess, actor=actor, action=verb, proposal_id=proposal_id, detail=detail)
return 0
finally:
if owned:
outer.__exit__(None, None, None)
# --- autonomous policy (the ONLY non-human approval path, D5) -----------------
def _auto_approve(cfg: Config, sess) -> int:
"""Auto-approve proposed items per policy. Returns count approved."""
if not cfg.autonomous_mode or not cfg.autonomous_kinds:
return 0
midnight = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
used = (
sess.query(func.count(AuditEntry.id))
.filter(
AuditEntry.actor == "auto:policy",
AuditEntry.action == "approve",
AuditEntry.ts >= midnight,
)
.scalar()
)
budget = max(0, cfg.autonomous_daily_cap - int(used or 0))
approved = 0
if budget == 0:
return 0
# most relevant first (match_score = keyword relevance, NOT risk — the
# audit's naming finding; ascending order approved noise first)
candidates = (
sess.query(Proposal)
.filter(
Proposal.status == ProposalStatus.PROPOSED,
Proposal.kind.in_(cfg.autonomous_kinds),
)
.order_by(Proposal.match_score.desc(), Proposal.id.asc())
.limit(budget)
.all()
)
for proposal in candidates:
proposal.status = ProposalStatus.APPROVED
proposal.approved_by = "auto:policy"
proposal.approved_at = datetime.now(UTC)
audit(
sess,
actor="auto:policy",
action="approve",
proposal_id=proposal.id,
detail={
"reason": "autonomous_mode policy",
"kind": proposal.kind,
"match_score": proposal.match_score,
"daily_cap": cfg.autonomous_daily_cap,
},
)
approved += 1
return approved
# --- execution ----------------------------------------------------------------
def execute_one(proposal: Proposal, cfg: Config, sess) -> bool:
"""Run the plugin for one approved proposal. Returns True on success."""
proposal.status = ProposalStatus.RUNNING
proposal.attempts += 1
audit(
sess,
actor="daemon:executor",
action="execute_start",
proposal_id=proposal.id,
detail={"plugin": proposal.kind, "attempt": proposal.attempts},
)
plugin = plugin_registry.REGISTRY.get(proposal.kind)
try:
if plugin is None:
outcome: dict[str, Any] = plugin_registry.log_action(proposal, cfg, sess)
outcome["plugin_fallback"] = True
outcome["note"] = f"no plugin registered for kind {proposal.kind!r}"
else:
outcome = plugin(proposal, cfg, sess)
except Exception as exc: # noqa: BLE001 — plugin boundary: capture for audit
audit(
sess,
actor="daemon:executor",
action="execute_error",
proposal_id=proposal.id,
result="error",
detail={"error": str(exc)[:300], "attempt": proposal.attempts},
)
if proposal.attempts >= cfg.max_attempts:
proposal.status = ProposalStatus.FAILED
audit(
sess,
actor="daemon:executor",
action="fail",
proposal_id=proposal.id,
result="error",
detail={"attempts": proposal.attempts},
)
else:
proposal.status = ProposalStatus.APPROVED # retry next run
return False
proposal.status = ProposalStatus.DONE
audit(
sess,
actor="daemon:executor",
action="execute_done",
proposal_id=proposal.id,
detail={"outcome": outcome},
)
return True
def run(cfg: Config | None = None, sess=None) -> int:
"""CLI entry: auto-approve per policy, then execute all approved items."""
from cayceops.config import load
from cayceops.db import engine as _engine
cfg = cfg or load()
owned = False
if sess is None:
outer = session_scope()
sess = outer.__enter__()
owned = True
try:
run_row = DaemonRun(daemon="executor")
sess.add(run_row)
sess.flush()
try:
auto = _auto_approve(cfg, sess)
pending = (
sess.query(Proposal)
.filter(Proposal.status == ProposalStatus.APPROVED)
.order_by(Proposal.approved_at.asc())
.all()
)
done = failed = 0
for proposal in pending:
if execute_one(proposal, cfg, sess):
done += 1
else:
failed += 1
run_row.status = "ok"
run_row.detail = {"auto_approved": auto, "done": done, "failed": failed}
except Exception as exc: # noqa: BLE001 — daemon boundary
run_row.status = "error"
run_row.detail = {"error": str(exc)[:300]}
run_row.finished_at = datetime.now(UTC)
return 1
run_row.finished_at = datetime.now(UTC)
return 0
finally:
if owned:
outer.__exit__(None, None, None)
_engine().dispose()
def select_approved(sess) -> list[Proposal]:
"""Helper for tests/tools: currently approved proposals, oldest first."""
return list(
sess.scalars(
select(Proposal)
.where(Proposal.status == ProposalStatus.APPROVED)
.order_by(Proposal.approved_at.asc())
).all()
)
ActionPlugin = Callable[[Proposal, Config, Any], dict]
+78
View File
@@ -0,0 +1,78 @@
"""executor action plugins.
A plugin maps a proposal ``kind`` to a side effect. Plugins are repo code —
they are reviewed like everything else and must:
- take ``(proposal, cfg, sess)`` and return a JSON-safe dict (the audit outcome)
- do their own permission checks (see ``http_post`` + ENABLE_HTTP_ACTIONS)
- raise on failure (the executor captures the exception into the audit log)
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from typing import Any
from cayceops.config import Config
from cayceops.models import Proposal, audit
REGISTRY: dict[str, Any] = {}
def register(kind: str):
"""Decorator: register an action plugin for a proposal kind."""
def deco(fn):
REGISTRY[kind] = fn
return fn
return deco
@register("log_action")
def log_action(proposal: Proposal, cfg: Config, sess) -> dict:
"""No-side-effect plugin: record the execution in the audit log only."""
return {"action": "logged", "title": proposal.title[:200]}
@register("http_post")
def http_post(proposal: Proposal, cfg: Config, sess) -> dict:
"""POST the proposal payload to a webhook.
Inert unless ``CAYCE_OPS_ENABLE_HTTP_ACTIONS=true``. Target URL comes from
the proposal detail (``detail['webhook_url']``) — operator-controlled data.
"""
if not cfg.enable_http_actions:
audit(
sess,
actor="daemon:executor",
action="http_post_blocked",
proposal_id=proposal.id,
result="error",
detail={"reason": "CAYCE_OPS_ENABLE_HTTP_ACTIONS is false"},
)
raise PermissionError("http_post disabled (CAYCE_OPS_ENABLE_HTTP_ACTIONS=false)")
url = (proposal.detail or {}).get("webhook_url", "")
if not url:
raise ValueError("proposal detail.webhook_url missing")
payload = json.dumps(
{"proposal_id": proposal.id, "kind": proposal.kind, "title": proposal.title}
).encode()
req = urllib.request.Request(
url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=20.0) as resp: # noqa: S310
return {"action": "http_post", "status": resp.status}
# proposals from the scanner default to content_gap/affiliate/partnership kinds;
# until dedicated plugins exist for those kinds they must not do anything
# network-visible. register more plugins here as the operator approves them.
for _safe_kind in ("affiliate", "partnership", "content_gap", "other"):
REGISTRY.setdefault(_safe_kind, log_action)
+1
View File
@@ -0,0 +1 @@
"""maild package."""
+105
View File
@@ -0,0 +1,105 @@
"""maild classification: rules first, LLM optional, conservative threshold.
The LLM never decides alone: any result below ``CAYCE_OPS_LLM_REVIEW_THRESHOLD``
confidence becomes ``needs_review``. With the ``null`` provider (default) only
deterministic rules run.
"""
from __future__ import annotations
import json
import re
from cayceops.config import Config
from cayceops.models import MailClassification
# NEGATED approval forms are rejections and must be checked FIRST:
# "We cannot accept your application", "not accepting new partners",
# "Your application was not approved" used to match the bare approval
# keywords (accept/approv) and read as approvals — audit finding 4.
_NEGATED_APPROVAL = (
MailClassification.REJECTION,
re.compile(
r"\b(cannot|can.?t|will not|won.?t|do not|don.?t|does not|doesn.?t)\s+"
r"(be\s+able\s+to\s+)?(accept|approve|proceed|offer|welcome)"
r"|\bnot\s+(been\s+)?approved\b"
r"|\bnot\s+accepting\b"
r"|\bunfortunately\b",
re.I,
),
0.95,
)
_RULES: list[tuple[MailClassification, re.Pattern[str], float]] = [
_NEGATED_APPROVAL,
# (class, pattern, confidence when matched)
(
MailClassification.REJECTION,
# noqa: E501 (kept on one line: the pattern IS the reviewed artifact)
re.compile(r"\b(declin|reject|not able to|unable to|we regret|won.t be able)\w*", re.I), # noqa: E501
0.9,
),
(
MailClassification.APPROVAL,
re.compile(r"\b(approv|accept|welcome to the|you.re (in|approved)|live|activated|glad to)\w*", re.I), # noqa: E501
0.85,
),
(
MailClassification.QUESTION,
re.compile(r"\b(could you|can you|please (provide|clarify|confirm)|what (is|are)|how (do|does|can)|need more (info|details))\b", re.I), # noqa: E501
0.7,
),
]
# braces are DOUBLED so .format() does not die on the literal JSON sample
# (the old prompt raised KeyError on every call and the generic except
# silently degraded to rules — audit finding 4)
_LLM_PROMPT = """Classify this merchant email into exactly one of:
approval, rejection, question, other.
Reply with strict JSON: {{"classification": "...", "confidence": 0.0-1.0}}
Subject and body:
{body}"""
def _rules(text: str) -> tuple[MailClassification, float]:
for kind, pattern, confidence in _RULES:
if pattern.search(text):
return kind, confidence
return MailClassification.OTHER, 0.3
def _llm_classify(text: str, cfg: Config) -> tuple[MailClassification, float] | None:
"""Ask the configured LLM. Returns None on any failure (rules take over)."""
from cayceops.llm import get_provider
try:
provider = get_provider(cfg)
raw = provider.complete(
_LLM_PROMPT.format(body=text[:4000]),
system="You classify merchant emails. Reply with JSON only.",
)
data = json.loads(re.search(r"\{.*\}", raw, re.S).group(0))
kind = MailClassification(data["classification"].lower())
confidence = float(data["confidence"])
except Exception: # noqa: BLE001 — LLM is best-effort by design
return None
if kind not in MailClassification or kind is MailClassification.NEEDS_REVIEW:
return None
return kind, min(max(confidence, 0.0), 1.0)
def classify(text: str, cfg: Config) -> tuple[MailClassification, float]:
"""Classify merchant text. Degrades to rules when no LLM is configured."""
if cfg.llm_provider != "null":
llm_result = _llm_classify(text, cfg)
if llm_result is not None:
kind, confidence = llm_result
else: # LLM failed — fall through to rules
kind, confidence = _rules(text)
else:
kind, confidence = _rules(text)
if confidence < cfg.llm_review_threshold:
return MailClassification.NEEDS_REVIEW, confidence
return kind, confidence
+566
View File
@@ -0,0 +1,566 @@
"""maild — classify merchant replies, draft responses, gated SMTP.
Flow
----
1. ``poll``: fetch unread inbound mail (IMAP), parse, thread to the proposal
via ``In-Reply-To`` of a previously-sent draft, classify (rules + optional
LLM), store, and create a REPLY DRAFT (never sent automatically).
2. ``approve_draft``: a human approves a specific draft (audited).
3. ``send_approved``: SMTP sends *only* drafts in state ``approved``.
Classification is conservative: anything below the confidence threshold becomes
``needs_review`` and no draft is generated.
Public signatures match the CLI: ``poll()``, ``approve_draft(id, actor)``,
``send_approved()``. The ``_*`` variants take an explicit session/fetcher for
tests and composition.
"""
from __future__ import annotations
import email
import email.policy
import imaplib
import smtplib
from datetime import UTC, datetime
from email.message import EmailMessage
from cayceops.components.maild.classify import classify as _classify_text
from cayceops.config import Config
from cayceops.db import session_scope
from cayceops.models import (
DaemonRun,
DraftStatus,
MailClassification,
MailDirection,
MailMessage,
Proposal,
_content_hash,
audit,
)
# --- IMAP ---------------------------------------------------------------------
def connect_imap(cfg: Config) -> imaplib.IMAP4_SSL:
client = imaplib.IMAP4_SSL(cfg.imap_host, cfg.imap_port)
client.login(cfg.imap_user, cfg.imap_password)
client.select(cfg.imap_mailbox)
return client
def _fetch_unread(cfg: Config) -> list[tuple[bytes, bytes]]:
"""Fetch raw unread messages as [(imap_num, raw_bytes)].
Messages are NOT marked \\Seen here — marking happens only after the
processed rows are committed (audit finding: a crash mid-batch used to
silently lose the rest of the batch).
"""
client = connect_imap(cfg)
try:
_, data = client.search(None, "UNSEEN")
out: list[tuple[bytes, bytes]] = []
for num in data[0].split():
_, msg_data = client.fetch(num, "(RFC822)")
out.append((num, msg_data[0][1]))
return out
finally:
client.logout()
def _mark_seen(cfg: Config, nums: list[bytes]) -> None:
"""Mark processed messages \\Seen (called after commit)."""
if not nums:
return
client = connect_imap(cfg)
try:
for num in nums:
client.store(num, "+FLAGS", "\\Seen")
finally:
client.logout()
# --- parsing ------------------------------------------------------------------
def parse_raw(raw: bytes) -> dict:
"""Parse raw RFC822 bytes into our flat dict shape."""
msg = email.message_from_bytes(raw, policy=email.policy.default)
body = ""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain" and not part.get_filename():
body = part.get_content()
break
else:
body = msg.get_content()
reply_to = msg.get("In-Reply-To") or msg.get("References") or ""
message_id = (msg.get("Message-ID") or "").strip()
if not message_id:
message_id = f"<generated-{datetime.now(UTC).timestamp()}@localhost>"
# store the bare address (envelope-safe); display names stay out of the DB
from_addr = email.utils.parseaddr(msg.get("From") or "")[1].strip()[:320]
return {
"message_id": message_id[:998],
"in_reply_to": reply_to.split()[-1].strip() if reply_to.strip() else None,
"from_addr": from_addr,
"subject": (msg.get("Subject") or "").strip()[:998],
"body": body[:20000],
}
# --- proposal threading -------------------------------------------------------
def _match_proposal(parsed: dict, sess) -> Proposal | None:
"""Thread an inbound mail to its proposal.
1. In-Reply-To matches a SENT OUTBOUND message (our replies carry their own
Message-ID and an outbound row, created at send time).
2. Fallback: it matches ANY stored message (e.g. a second reply threaded to
our first inbound) that already carries a proposal_id.
"""
irt = parsed.get("in_reply_to")
if not irt:
return None
sent = (
sess.query(MailMessage)
.filter(
MailMessage.direction == MailDirection.OUTBOUND,
MailMessage.message_id == irt,
)
.one_or_none()
)
if sent is not None and sent.proposal_id is not None:
return sess.get(Proposal, sent.proposal_id)
chain = (
sess.query(MailMessage)
.filter(MailMessage.message_id == irt, MailMessage.proposal_id.isnot(None))
.one_or_none()
)
if chain is not None:
return sess.get(Proposal, chain.proposal_id)
return None
# --- drafts -------------------------------------------------------------------
_DRAFT_TEMPLATES = {
MailClassification.APPROVAL: (
"Hello,\n\nThank you for the approval regarding \"{title}\". "
"We will proceed on our side and follow up with the next steps.\n\nBest regards,\nCayce"
),
MailClassification.REJECTION: (
"Hello,\n\nThank you for considering \"{title}\". "
"We appreciate the reply and would welcome the chance to revisit in the future.\n\n"
"Best regards,\nCayce"
),
MailClassification.QUESTION: (
"Hello,\n\nRegarding \"{title}\": {question}\n\n"
"Could you help us with the details so we can move forward?\n\nBest regards,\nCayce"
),
}
def _build_draft(parsed: dict, kind: MailClassification, proposal: Proposal | None) -> str:
title = proposal.title if proposal else (parsed.get("subject") or "your message")
template = _DRAFT_TEMPLATES.get(kind)
if template is None:
return ""
question = (parsed.get("body") or "")[:200].strip() or "we had a question"
return template.format(title=title[:120], question=question)
# --- inbound pass ---------------------------------------------------------------
def _poll(cfg: Config, sess, fetcher=None, marker=None) -> tuple[dict, list[bytes]]:
"""One inbound pass: parse, classify, thread, draft.
Returns (stats, processed_imap_nums). The caller marks \\Seen only after
its transaction commits.
"""
stats = {"processed": 0, "drafted": 0, "needs_review": 0, "skipped": 0}
processed_nums: list[bytes] = []
for num, raw in (fetcher or _fetch_unread)(cfg):
parsed = parse_raw(raw)
if (
sess.query(MailMessage)
.filter(MailMessage.message_id == parsed["message_id"])
.one_or_none()
):
stats["skipped"] += 1
continue
proposal = _match_proposal(parsed, sess)
kind, confidence = _classify_text(f"{parsed['subject']}\n{parsed['body']}", cfg)
if proposal is None and kind == MailClassification.APPROVAL:
# an approval we cannot thread is suspicious — human must look
kind = MailClassification.NEEDS_REVIEW
confidence = min(confidence, 0.4)
record = MailMessage(
direction=MailDirection.INBOUND,
message_id=parsed["message_id"],
in_reply_to=parsed["in_reply_to"],
from_addr=parsed["from_addr"] or None,
subject=parsed["subject"] or None,
body=parsed["body"],
classification=kind,
confidence=confidence,
proposal_id=proposal.id if proposal else None,
processed_at=datetime.now(UTC),
)
sess.add(record)
sess.flush()
audit(
sess,
actor="daemon:maild",
action="classify",
proposal_id=proposal.id if proposal else None,
detail={"classification": kind.value, "confidence": confidence},
)
stats["processed"] += 1
processed_nums.append(num) # stored -> safe to mark seen after commit
if kind == MailClassification.NEEDS_REVIEW:
stats["needs_review"] += 1
continue
if proposal is not None:
proposal.detail = {
**(proposal.detail or {}),
"merchant_status": kind.value,
"merchant_reply_at": datetime.now(UTC).isoformat(),
}
draft = _build_draft(parsed, kind, proposal)
if draft:
record.draft_reply = draft
record.draft_status = DraftStatus.DRAFTED
audit(
sess,
actor="daemon:maild",
action="draft_reply",
proposal_id=proposal.id if proposal else None,
detail={"mail_id": record.id},
)
stats["drafted"] += 1
return stats, processed_nums
# --- human gate: approve draft -------------------------------------------------
def _approve_draft(mail_id: int, actor: str, sess) -> int:
"""Human approves a drafted reply (inbound-threaded or first-contact).
The approval is BOUND to the draft text via sha256 — a later edit breaks
the binding and the send refuses (audit finding: approved text was not
tied to its content).
"""
record = sess.get(MailMessage, mail_id)
if record is None:
return 1
if record.draft_status != DraftStatus.DRAFTED or not record.draft_reply:
audit(
sess,
actor=actor,
action="approve_draft",
proposal_id=record.proposal_id,
result="error",
detail={"reason": f"invalid draft state: {record.draft_status.value}"},
)
return 1
record.draft_status = DraftStatus.APPROVED
record.approved_hash = _content_hash(record.draft_reply)
record.approved_by = actor[:128]
audit(
sess,
actor=actor,
action="approve_draft",
proposal_id=record.proposal_id,
detail={"mail_id": record.id, "hash": record.approved_hash},
)
return 0
# --- SMTP: only approved drafts ------------------------------------------------
def _smtp_send(
cfg: Config,
to_addr: str,
subject: str,
body: str,
message_id: str,
in_reply_to: str | None = None,
) -> None:
msg = EmailMessage()
msg["From"] = cfg.mail_from
msg["To"] = to_addr
msg["Subject"] = subject
msg["Message-ID"] = message_id
if in_reply_to:
msg["In-Reply-To"] = in_reply_to # keep merchant threads intact
msg.set_content(body)
with smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) as server:
server.starttls()
server.login(cfg.smtp_user, cfg.smtp_password)
server.send_message(msg)
def _approved_by(sess, mail_id: int) -> str:
from cayceops.models import AuditEntry
for entry in sess.query(AuditEntry).all():
if entry.action == "approve_draft" and entry.detail.get("mail_id") == mail_id:
return entry.actor
return "unknown"
def _send_one(cfg: Config, sess, mail_id: int, sender=None) -> str:
"""Send ONE approved draft. Returns: sent | hash_mismatch | missing | blocked.
Creates the OUTBOUND row (its own Message-ID + In-Reply-To pointing at the
inbound message) so future replies thread correctly — the inbound row's
message_id is left untouched (audit finding: it used to be overwritten).
The caller owns the transaction: one message per commit, so an SMTP failure
on message N never un-sends messages 1..N-1 (audit finding: duplicates).
"""
from cayceops.models import _content_hash
record = sess.get(MailMessage, mail_id)
if record is None:
return "missing"
if record.draft_status != DraftStatus.APPROVED:
return "blocked"
if record.direction == MailDirection.INBOUND:
# reply: goes back to the inbound sender, threads to the inbound mail,
# and gets its own OUTBOUND row
to_addr = record.from_addr or ""
subject = f"Re: {record.subject or 'your message'}"
in_reply_to = record.message_id
outbound_msgid = (
f"<cayce-out-{record.id}-{int(datetime.now(UTC).timestamp())}@cayce-co.xyz>"
)
needs_row = True
else:
# first-contact draft: already an OUTBOUND row; send as-is
to_addr = record.to_addr or ""
subject = record.subject or ""
in_reply_to = None
outbound_msgid = record.message_id
needs_row = False
if not to_addr:
record.draft_status = DraftStatus.SKIPPED
return "blocked"
# binding: the text must be exactly what the human approved
if record.approved_hash and _content_hash(record.draft_reply or "") != record.approved_hash:
record.draft_status = DraftStatus.DRAFTED # needs fresh approval
audit(
sess,
actor="daemon:maild",
action="send_refused_hash_mismatch",
proposal_id=record.proposal_id,
result="error",
detail={"mail_id": record.id},
)
return "hash_mismatch"
(sender or _smtp_send)(
cfg,
to_addr,
subject,
record.draft_reply or "",
outbound_msgid,
in_reply_to=in_reply_to,
)
approved_by = _approved_by(sess, record.id)
if needs_row:
sess.add(
MailMessage(
direction=MailDirection.OUTBOUND,
message_id=outbound_msgid,
in_reply_to=record.message_id,
from_addr=cfg.mail_from,
to_addr=to_addr,
subject=subject,
body=record.draft_reply,
proposal_id=record.proposal_id,
draft_status=DraftStatus.SENT,
processed_at=datetime.now(UTC),
)
)
record.draft_status = DraftStatus.SENT
sess.flush()
audit(
sess,
actor="daemon:maild",
action="send_draft",
proposal_id=record.proposal_id,
detail={
"mail_id": record.id,
"message_id": outbound_msgid,
"approved_by": approved_by,
},
)
return "sent"
def _send_approved(cfg: Config, sess, sender=None, factory=None) -> dict:
"""Send every APPROVED draft, one transaction per message.
`factory` is a SQLAlchemy sessionmaker bound to the shared engine; each
message is processed and committed in its own session (tests inject the
engine's maker; the CLI passes appdb.session_factory()).
"""
stats = {"sent": 0, "blocked": 0, "hash_mismatch": 0, "missing": 0}
ids = [
row.id
for row in sess.query(MailMessage.id)
.filter(MailMessage.draft_status == DraftStatus.APPROVED)
.all()
]
for mail_id in ids:
# one transaction per message — an SMTP failure on message N must not
# roll back (and later re-send) messages 1..N-1 (audit finding 3)
try:
if factory is not None:
with factory() as one_sess: # sessionmaker() -> Session
outcome = _send_one(cfg, one_sess, mail_id, sender)
one_sess.commit()
else:
outcome = _send_one(cfg, sess, mail_id, sender)
except Exception as exc: # noqa: BLE001 — isolate the failed message
outcome = "error"
if sess is not None:
detail = {"mail_id": mail_id, "error": str(exc)[:200]}
audit(sess, actor="daemon:maild", action="send_failed",
result="error", detail=detail)
stats[outcome] = stats.get(outcome, 0) + 1
return stats
def _approved_by(sess, mail_id: int) -> str:
from cayceops.models import AuditEntry
for entry in sess.query(AuditEntry).all():
if entry.action == "approve_draft" and entry.detail.get("mail_id") == mail_id:
return entry.actor
return "unknown"
def _compose(proposal_id: int, to_addr: str, subject: str, body: str, sess) -> int:
"""Create the FIRST-CONTACT outbound draft for a proposal (state: drafted).
This is the missing path the audit flagged: without it, threading only
worked for replies to mails we never actually sent.
"""
proposal = sess.get(Proposal, proposal_id)
if proposal is None or not to_addr:
return -1
outbound = MailMessage(
direction=MailDirection.OUTBOUND,
message_id=f"<cayce-out-{proposal.id}-{int(datetime.now(UTC).timestamp())}@cayce-co.xyz>",
from_addr=None, # filled from MAIL_FROM at send time
to_addr=to_addr[:320],
subject=subject[:998],
body=body,
draft_reply=body, # canonical approved-text field (same as reply drafts)
proposal_id=proposal.id,
draft_status=DraftStatus.DRAFTED,
)
sess.add(outbound)
sess.flush()
audit(
sess,
actor="daemon:maild",
action="compose_draft",
proposal_id=proposal.id,
detail={"mail_id": outbound.id},
)
return outbound.id
# --- CLI wrappers (session scope lives here) ------------------------------------
def poll(cfg: Config | None = None) -> int:
from cayceops.config import load
from cayceops.db import engine
cfg = cfg or load()
try:
nums: list[bytes] = []
with session_scope() as sess:
run_row = DaemonRun(daemon="maild")
sess.add(run_row)
sess.flush()
try:
stats, nums = _poll(cfg, sess)
except Exception as exc: # noqa: BLE001 — daemon boundary
run_row.status = "error"
run_row.detail = {"error": str(exc)[:300]}
run_row.finished_at = datetime.now(UTC)
return 1
run_row.status = "ok"
run_row.detail = stats
run_row.finished_at = datetime.now(UTC)
# committed: now it is safe to mark the batch seen
if nums:
_mark_seen(cfg, nums)
return 0
finally:
engine().dispose()
def approve_draft(mail_id: int, actor: str) -> int:
from cayceops.db import engine
try:
with session_scope() as sess:
return _approve_draft(mail_id, actor, sess)
finally:
engine().dispose()
def send_approved(cfg: Config | None = None) -> int:
from cayceops.config import load
from cayceops.db import engine, session_factory
cfg = cfg or load()
try:
# ids first, then one transaction per message: a failure on message N
# cannot roll back already-sent messages (audit finding: duplicates)
with session_scope() as sess:
ids = [
row.id
for row in sess.query(MailMessage.id)
.filter(MailMessage.draft_status == DraftStatus.APPROVED)
.all()
]
factory = session_factory()
for mail_id in ids:
try:
with factory() as one_sess:
_send_one(cfg, one_sess, mail_id)
one_sess.commit()
except Exception as exc: # noqa: BLE001 — isolate each send
print(f"send {mail_id} failed: {str(exc)[:160]}")
return 0
finally:
engine().dispose()
def compose(proposal_id: int, to_addr: str, subject: str, body: str) -> int:
from cayceops.db import engine
try:
with session_scope() as sess:
mail_id = _compose(proposal_id, to_addr, subject, body, sess)
return 0 if mail_id >= 0 else 1
finally:
engine().dispose()
+1
View File
@@ -0,0 +1 @@
"""monitor package."""
+174
View File
@@ -0,0 +1,174 @@
"""monitor — static status page builder.
Reads the shared database and renders a self-contained HTML file (no external
assets, no JS, everything escaped). Contains NO sensitive data: daemon health,
proposal queue counts, and recent audit activity (actor/action/result only —
no email bodies, no detail payloads, no URLs).
"""
from __future__ import annotations
import html
from datetime import UTC, datetime
from sqlalchemy import func, select
from cayceops.config import Config
from cayceops.db import session_scope
from cayceops.models import (
AuditEntry,
ContentItem,
DaemonRun,
MailMessage,
Proposal,
ProposalStatus,
)
DAEMONS = ("scanner", "executor", "maild", "publisher", "monitor")
_STYLE = """<style>
body{font-family:ui-monospace,Menlo,monospace;background:#0a0908;color:#f2ece0;margin:2rem}
h1{color:#c9a84c;font-weight:600} h2{color:#c9a84c;margin-top:2rem;font-size:1.1rem}
table{border-collapse:collapse;margin:.5rem 0}
td,th{border:1px solid #333;padding:.35rem .8rem;text-align:left}
.ok{color:#6fce6f}.error{color:#ef6b6b}.muted{color:#888;font-size:.85rem}
.card{display:inline-block;background:#141210;border:1px solid #2a2a2a;
border-radius:8px;padding:.8rem 1.4rem;margin:.3rem}
</style>"""
def _fmt_ts(dt) -> str:
return dt.strftime("%Y-%m-%d %H:%M:%SZ") if dt else "—"
def _daemon_rows(sess) -> str:
rows = []
for name in DAEMONS:
last = (
sess.query(DaemonRun)
.filter(DaemonRun.daemon == name)
.order_by(DaemonRun.started_at.desc())
.first()
)
if last is None:
rows.append(
"<tr><td>" + name + "</td><td class=muted>never run</td><td>—</td></tr>"
)
else:
cls = "ok" if last.status == "ok" else "error"
when = _fmt_ts(last.finished_at or last.started_at)
rows.append(
"<tr><td>" + html.escape(name) + "</td>"
+ '<td class="' + cls + '">' + html.escape(last.status) + "</td>"
+ "<td>" + when + "</td></tr>"
)
head = "<tr><th>daemon</th><th>last status</th><th>last run</th></tr>"
return "<table>" + head + "".join(rows) + "</table>"
def _queue_cards(sess) -> str:
counts = dict(
sess.query(Proposal.status, func.count(Proposal.id)).group_by(Proposal.status).all()
)
cards = []
for status in ProposalStatus:
value = counts.get(status, 0)
cards.append(
"<div class=card>" + html.escape(status.value)
+ ": <strong>" + str(value) + "</strong></div>"
)
return "".join(cards)
def _recent_audit(sess, limit: int = 20) -> str:
rows = []
query = sess.query(AuditEntry).order_by(AuditEntry.ts.desc()).limit(limit)
for entry in query.all():
cls = "ok" if entry.result == "ok" else "error"
rows.append(
"<tr><td>" + _fmt_ts(entry.ts) + "</td>"
+ "<td>" + html.escape(entry.actor) + "</td>"
+ "<td>" + html.escape(entry.action) + "</td>"
+ '<td class="' + cls + '">' + html.escape(entry.result) + "</td></tr>"
)
body = "".join(rows) or "<tr><td colspan=4 class=muted>no activity yet</td></tr>"
return (
"<table><tr><th>time (utc)</th><th>actor</th><th>action</th><th>result</th></tr>"
+ body
+ "</table>"
)
def _build(cfg: Config, sess) -> str:
now = datetime.now(UTC)
mail_unprocessed = (
sess.query(func.count(MailMessage.id))
.filter(
MailMessage.direction == "in",
MailMessage.draft_status.in_(["drafted", "none"]),
MailMessage.classification.isnot(None),
)
.scalar()
)
content_pending = (
sess.query(func.count(ContentItem.id))
.filter(ContentItem.status.in_(["draft", "approved"]))
.scalar()
)
generated = _fmt_ts(now)
page = f"""<!doctype html>
<html lang=en><head><meta charset=utf-8><title>cayce-ops status</title>{_STYLE}</head>
<body>
<h1>cayce-ops · status</h1>
<p class=muted>generated {generated} · static page, no sensitive data by design</p>
<h2>daemon health</h2>
{_daemon_rows(sess)}
<h2>proposal queue</h2>
{_queue_cards(sess)}
<h2>gates</h2>
<div class=card>mail drafts awaiting approval: <strong>{mail_unprocessed}</strong></div>
<div class=card>content items draft/approved: <strong>{content_pending}</strong></div>
<h2>recent audit activity</h2>
{_recent_audit(sess)}
<p class=muted>audit entries: {sess.scalar(select(func.count(AuditEntry.id)))} total</p>
</body></html>"""
import os
os.makedirs(os.path.dirname(cfg.monitor_output) or ".", exist_ok=True)
with open(cfg.monitor_output, "w", encoding="utf-8") as fh:
fh.write(page)
return cfg.monitor_output
def build(cfg: Config | None = None) -> int:
"""CLI entry: render the status page inside a DaemonRun bookkeeping wrap."""
from cayceops.config import load
from cayceops.db import engine
cfg = cfg or load()
try:
with session_scope() as sess:
run_row = DaemonRun(daemon="monitor")
sess.add(run_row)
sess.flush()
try:
out = _build(cfg, sess)
except Exception as exc: # noqa: BLE001 — daemon boundary
run_row.status = "error"
run_row.detail = {"error": str(exc)[:300]}
run_row.finished_at = datetime.now(UTC)
return 1
run_row.status = "ok"
run_row.detail = {"output": out}
run_row.finished_at = datetime.now(UTC)
print(out)
return 0
finally:
engine().dispose()
@@ -0,0 +1 @@
"""publisher package."""
+61
View File
@@ -0,0 +1,61 @@
"""Optional Facebook/Instagram adapters (Graph API).
DISABLED BY DEFAULT. Each adapter refuses to run unless its enable flag is
true (``CAYCE_OPS_ENABLE_FACEBOOK`` / ``CAYCE_OPS_ENABLE_INSTAGRAM``), so the
default deployment never touches Meta endpoints. Tokens are env-only and are
never logged.
"""
from __future__ import annotations
import json
import urllib.parse
import urllib.request
GRAPH = "https://graph.facebook.com/v21.0"
def _post(url: str, payload: dict) -> dict:
data = urllib.parse.urlencode(payload).encode()
req = urllib.request.Request(url, data=data, method="POST") # noqa: S310
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
def post_facebook(cfg, caption: str, media_path: str | None = None) -> dict:
"""Post to the page feed (text) or /photos (with local media). Gated."""
if not cfg.enable_facebook:
raise PermissionError("facebook adapter disabled (CAYCE_OPS_ENABLE_FACEBOOK=false)")
if not cfg.facebook_page_token:
raise ValueError("CAYCE_OPS_FACEBOOK_PAGE_TOKEN missing")
if media_path:
# photo posts require a reachable URL or multipart upload; we use the
# simple URL form — media must be hosted (out of scope for local files)
result = _post(
f"{GRAPH}/me/photos",
{"url": media_path, "caption": caption, "access_token": cfg.facebook_page_token},
)
else:
result = _post(
f"{GRAPH}/me/feed",
{"message": caption, "access_token": cfg.facebook_page_token},
)
return {"post_id": result.get("post_id") or result.get("id", "")}
def post_instagram(cfg, caption: str, image_url: str) -> dict:
"""Two-step IG publish (container -> publish). Gated."""
if not cfg.enable_instagram:
raise PermissionError("instagram adapter disabled (CAYCE_OPS_ENABLE_INSTAGRAM=false)")
if not cfg.instagram_user_id:
raise ValueError("CAYCE_OPS_INSTAGRAM_USER_ID missing")
token = cfg.facebook_page_token # IG Graph publishing uses the page token
container = _post(
f"{GRAPH}/{cfg.instagram_user_id}/media",
{"image_url": image_url, "caption": caption, "access_token": token},
)
published = _post(
f"{GRAPH}/{cfg.instagram_user_id}/media_publish",
{"creation_id": container["id"], "access_token": token},
)
return {"post_id": published.get("id", "")}
+82
View File
@@ -0,0 +1,82 @@
"""Brand image templates (Pillow). 1080x1350 portrait, two brand styles.
Deterministic, template-driven graphics — deliberately not AI-generated media
(DECISIONS: nothing here trips AI-media disclosure requirements; it is typeset
design). Fonts: Pillow's bundled default (scalable), so no system font deps.
"""
from __future__ import annotations
import os
from PIL import Image, ImageDraw, ImageFont
WIDTH, HEIGHT = 1080, 1350
_BRANDS = {
# bitcoinhotels — warm gold on near-black
"bh": {
"bg": (20, 18, 16),
"accent": (201, 168, 76),
"text": (242, 236, 224),
"muted": (150, 140, 120),
},
# hardcap — bitcoin orange on cool black
"hc": {
"bg": (13, 13, 16),
"accent": (247, 147, 26),
"text": (250, 250, 250),
"muted": (120, 120, 130),
},
}
def _font(size: int, bold: bool = False):
# Pillow >=10: load_default(size=...) returns a scalable bundled font
return ImageFont.load_default(size=size)
def _vgrad(top: tuple, bottom: tuple) -> Image.Image:
"""Simple two-color vertical gradient."""
img = Image.new("RGB", (WIDTH, HEIGHT))
for y in range(HEIGHT):
t = y / (HEIGHT - 1)
row = tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3))
ImageDraw.Draw(img).line([(0, y), (WIDTH, y)], fill=row)
return img
def render_brand_image(
*,
brand: str = "bh",
kicker: str | None,
lines: list[str],
tagline: list[str] | None = None,
url: str | None = None,
out_path: str,
) -> str:
"""Render one branded poster. Returns out_path. Raises on unknown brand."""
if brand not in _BRANDS:
raise ValueError(f"unknown brand {brand!r} (have: {sorted(_BRANDS)})")
palette = _BRANDS[brand]
img = _vgrad(palette["bg"], tuple(max(0, c - 8) for c in palette["bg"]))
draw = ImageDraw.Draw(img)
x, y = 90, 240
if kicker:
draw.text((x, y), kicker, font=_font(40), fill=palette["muted"])
y += 110
for line in lines:
draw.text((x, y), line, font=_font(96, bold=True), fill=palette["text"])
y += 118
y += 24
for t in tagline or []:
draw.text((x, y), t, font=_font(38), fill=palette["muted"])
y += 58
draw.rectangle([x, y + 30, x + 470, y + 33], fill=palette["accent"])
if url:
draw.text((x, y + 110), url, font=_font(44), fill=palette["accent"])
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
img.save(out_path, "JPEG", quality=92)
return out_path
+186
View File
@@ -0,0 +1,186 @@
"""Minimal Nostr client: NIP-01 kind-1 notes (build, sign, verify, publish).
Implemented in-repo (DECISIONS D4) so every byte of the signing path is
auditable: event id = sha256 of the canonical NIP-01 serialization; signature =
BIP-340 Schnorr over the id. Keys are 32-byte hex. Relays are plain env config.
Publishing is fire-and-wait: open a websocket per relay, send the EVENT, wait
for the OK frame (or timeout), and return the per-relay results.
"""
from __future__ import annotations
import hashlib
import json
from typing import Any
from coincurve import PrivateKey, PublicKey
KIND_NOTE = 1
def normalize_key(private_key_hex: str) -> bytes:
"""Accept 64-char hex (with or without 0x). Anything else is a config error."""
key = private_key_hex.strip().lower().removeprefix("0x")
if len(key) != 64 or not all(c in "0123456789abcdef" for c in key):
raise ValueError("CAYCE_OPS_NOSTR_PRIVATE_KEY must be 64-char hex (32 bytes)")
return bytes.fromhex(key)
def pubkey_from_secret(secret: bytes) -> str:
return PublicKey.from_secret(secret).format().hex()[2:]
def build_event(
secret: bytes,
content: str,
tags: list[list[str]] | None = None,
created_at: int | None = None,
kind: int = KIND_NOTE,
) -> dict[str, Any]:
"""Build and sign a NIP-01 event. Deterministic given the same inputs."""
created_at = int(created_at if created_at is not None else __import__("time").time())
pubkey = pubkey_from_secret(secret)
serialized = json.dumps(
[0, pubkey, created_at, kind, tags or [], content],
separators=(",", ":"), # canonical NIP-01 form
ensure_ascii=False,
)
event_id = hashlib.sha256(serialized.encode("utf-8")).hexdigest()
# zero aux randomness -> fully deterministic signatures (BIP-340 permits it;
# verification is unaffected either way)
signature = (
PrivateKey(secret)
.sign_schnorr(bytes.fromhex(event_id), aux_randomness=b"\x00" * 32)
.hex()
)
return {
"pubkey": pubkey,
"created_at": created_at,
"kind": kind,
"tags": tags or [],
"content": content,
"id": event_id,
"sig": signature,
}
def _schnorr_verify(sig32: bytes, msg32: bytes, xonly_pub32: bytes) -> bool:
"""BIP-340 reference verification (pure Python, public domain spec code).
Signing runs in coincurve (libsecp256k1); verification is pure Python so
the auditor can read every step without CFFI indirection.
"""
P = 2**256 - 2**32 - 977
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
def tag_hash(tag: str, msg: bytes) -> bytes:
t = hashlib.sha256(tag.encode()).digest()
return hashlib.sha256(t + t + msg).digest()
def lift_x(b: int):
if b >= P:
return None
y_sq = (pow(b, 3, P) + 7) % P
y = pow(y_sq, (P + 1) // 4, P)
if pow(y, 2, P) != y_sq:
return None
return (b, y if y % 2 == 0 else P - y)
def point_add(p1, p2):
if p1 is None:
return p2
if p2 is None:
return p1
if p1[0] == p2[0] and p1[1] != p2[1]:
return None
if p1 == p2:
lam = (3 * p1[0] * p1[0] * pow(2 * p1[1], P - 2, P)) % P
else:
lam = ((p2[1] - p1[1]) * pow(p2[0] - p1[0], P - 2, P)) % P
x3 = (lam * lam - p1[0] - p2[0]) % P
return (x3, (lam * (p1[0] - x3) - p1[1]) % P)
def point_mul(p, scalar):
acc = None
for i in range(256):
if (scalar >> i) & 1:
acc = point_add(acc, p)
p = point_add(p, p)
return acc
if len(sig32) != 64 or len(msg32) != 32 or len(xonly_pub32) != 32:
return False
r = int.from_bytes(sig32[:32], "big")
s = int.from_bytes(sig32[32:], "big")
if not (0 <= r < P and 0 <= s < N):
return False
pub_point = lift_x(int.from_bytes(xonly_pub32, "big"))
if pub_point is None:
return False
e = int.from_bytes(tag_hash("BIP0340/challenge", sig32[:32] + xonly_pub32 + msg32), "big") % N
generator = (
0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, # Gx
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8, # Gy
)
r_point = point_add(
point_mul(generator, s),
point_mul(pub_point, (N - e) % N),
)
if r_point is None or r_point[1] % 2 != 0:
return False
return r_point[0] == r
def verify_event(event: dict[str, Any]) -> bool:
"""Recompute the id and verify the Schnorr signature against the pubkey."""
serialized = json.dumps(
[0, event["pubkey"], event["created_at"], event["kind"], event["tags"], event["content"]],
separators=(",", ":"),
ensure_ascii=False,
)
if hashlib.sha256(serialized.encode("utf-8")).hexdigest() != event["id"]:
return False
# 33-byte compressed point (02-prefixed x-only)
compressed = PublicKey(bytes.fromhex("02" + event["pubkey"])).format()
return _schnorr_verify(
bytes.fromhex(event["sig"]), bytes.fromhex(event["id"]), compressed[1:]
)
def verify_schnorr(sig_hex: str, message: bytes, pubkey_hex: str) -> bool:
"""Verify a BIP-340 signature over an arbitrary 32-byte message."""
try:
return _schnorr_verify(
bytes.fromhex(sig_hex), message, bytes.fromhex(pubkey_hex)
)
except (ValueError, TypeError):
return False
def relay_publish(relay_url: str, event: dict[str, Any], timeout: float = 10.0) -> str:
"""Publish one event to one relay; return the relay's OK message."""
from websockets.sync.client import connect
with connect(relay_url, close_timeout=timeout) as ws:
ws.send(json.dumps(["EVENT", event]))
reply = json.loads(ws.recv(timeout=timeout))
if not (isinstance(reply, list) and len(reply) >= 3 and reply[0] == "OK" and reply[2] is True):
raise RuntimeError(f"relay {relay_url} rejected event: {str(reply)[:200]}")
return str(reply[1])
def publish(
relays: list[str], event: dict[str, Any], timeout: float = 10.0
) -> list[dict[str, str]]:
"""Publish to every relay; report per-relay outcome (never raises unless all fail)."""
results = []
for relay in relays:
try:
relay_publish(relay, event, timeout)
results.append({"relay": relay, "ok": "true"})
except Exception as exc: # noqa: BLE001 — per-relay isolation
results.append({"relay": relay, "ok": "false", "error": str(exc)[:200]})
if relays and all(r["ok"] == "false" for r in results):
raise RuntimeError(f"all relays failed: {results}")
return results
+322
View File
@@ -0,0 +1,322 @@
"""publisher — themed content calendar with a hard human gate on publishing.
State machine: draft -> approved -> published. Only ``approved`` items ever
publish. AUTONOMOUS_MODE has NO effect on this component: content always
requires explicit human approval (documented in AUDIT.md).
Channels: nostr (NIP-01, primary) — facebook/instagram adapters exist but are
disabled unless explicitly enabled via env (DECISIONS: default deployment never
touches Meta).
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from cayceops.components.publisher import images, nostr
from cayceops.config import Config
from cayceops.db import session_scope
from cayceops.models import (
ContentItem,
ContentStatus,
DaemonRun,
_content_hash,
audit,
)
# rotation: one theme per slot; channel rotation nostr -> facebook -> instagram
THEMES = [
("lightning-abroad", "bitcoinhotels.org/guides/lightning-abroad"),
("seed-strategy", "hardcap.news/learn/travel-security-kit"),
("atm-fees", "bitcoinhotels.org/guides/best-bitcoin-atms-by-city"),
("elsalvador-budget", "bitcoinhotels.org/guides/living-on-sats-in-el-salvador"),
]
_CAPTION_TEMPLATE = "{hook}\n\n{body}\n\n{link}"
def _caption(theme: str, link: str) -> str:
hooks = {
"lightning-abroad": "Paying abroad with Lightning beats your bank.",
"seed-strategy": "The seed strategy you pick decides how the border story goes.",
"atm-fees": "The first ATM you find charges 10-15% over spot.",
"elsalvador-budget": "A real weekly budget on a Bitcoin standard.",
}
bodies = {
"lightning-abroad": (
"No foreign transaction fees, no dynamic currency conversion, "
"no declines. Field-tested guide."
),
"seed-strategy": (
"Steel plate, memorized + decoy, or split custody — "
"the full breakdown, brutally honest."
),
"atm-fees": (
"The prepared traveler pays 4-6%. The difference is knowing "
"the operator and the no-KYC cap."
),
"elsalvador-budget": (
"Surf hostels, comidas, coworking — the corners where you still "
"need dollars, and the 90-day protocol."
),
}
return _CAPTION_TEMPLATE.format(
hook=hooks.get(theme, theme.replace("-", " ").title()),
body=bodies.get(theme, "Field-tested notes."),
link=link,
)
# --- calendar planning ----------------------------------------------------------
def _plan(cfg: Config, sess, days: int = 4) -> dict:
"""Fill the calendar: one draft per theme/channel pair not already queued.
Idempotent by (theme, channel): existing draft/approved items block a
duplicate. Drafts are scheduled one per day, starting tomorrow.
"""
created = 0
for idx, (theme, link) in enumerate(THEMES[:days]):
channel = ["nostr", "facebook", "instagram", "nostr"][idx % 4]
exists = (
sess.query(ContentItem)
.filter(
ContentItem.theme == theme,
ContentItem.channel == channel,
ContentItem.status != ContentStatus.PUBLISHED,
)
.one_or_none()
)
if exists:
continue
item = ContentItem(
theme=theme,
channel=channel,
caption=_caption(theme, link),
link=link,
status=ContentStatus.DRAFT,
scheduled_for=datetime.now(UTC).replace(hour=13, minute=0, second=0, microsecond=0)
+ timedelta(days=idx + 1),
)
sess.add(item)
sess.flush()
audit(
sess,
actor="daemon:publisher",
action="plan_draft",
detail={"item": item.id, "theme": theme, "channel": channel},
)
created += 1
return {"created": created}
# --- human gate -------------------------------------------------------------------
def _approve(item_id: int, actor: str, sess) -> int:
item = sess.get(ContentItem, item_id)
if item is None or item.status != ContentStatus.DRAFT:
audit(
sess,
actor=actor,
action="approve_content",
result="error",
detail={"reason": f"item {item_id} not in draft state"},
)
return 1
item.status = ContentStatus.APPROVED
item.approved_hash = _content_hash(item.caption) # binding: caption as approved
audit(
sess,
actor=actor,
action="approve_content",
detail={"item": item.id, "channel": item.channel, "hash": item.approved_hash},
)
return 0
# --- image rendering ----------------------------------------------------------------
def _render_image(item: ContentItem, cfg: Config) -> str | None:
"""Render the brand image for a nostr item (per its theme)."""
brand = "hc" if "hardcap" in (item.link or "") else "bh"
title = item.theme.replace("-", " ").upper().split()
lines = [" ".join(title[:2])] if title else [item.theme]
out = images.render_brand_image(
brand=brand,
kicker="C A Y C E",
lines=lines,
tagline=[(item.link or "")[:70]],
url=(item.link or "")[:60],
out_path=f"{cfg.media_dir}/item-{item.id}.jpg",
)
item.media_path = out
return out
# --- publishing ----------------------------------------------------------------------
def _publish_nostr(item: ContentItem, cfg: Config, sess, sender=None) -> str:
if not cfg.nostr_private_key or not cfg.nostr_relays:
raise RuntimeError(
"nostr channel requires CAYCE_OPS_NOSTR_PRIVATE_KEY and CAYCE_OPS_NOSTR_RELAYS"
)
secret = nostr.normalize_key(cfg.nostr_private_key)
event = nostr.build_event(secret, item.caption, tags=[["t", item.theme]])
if not nostr.verify_event(event): # pragma: no cover — build/verify symmetry
raise RuntimeError("event failed self-verification before publish")
results = (sender or nostr.publish)(cfg.nostr_relays, event)
audit(
sess,
actor="daemon:publisher",
action="nostr_relay_results",
detail={"event": event["id"], "relays": results},
)
return event["id"]
def _publish_due(cfg: Config, sess, nostr_sender=None) -> dict:
"""Publish approved items that are due. Non-nostr channels are skipped
unless their adapters are explicitly enabled (default: skipped, audited)."""
stats = {"published": 0, "skipped": 0, "failed": 0, "tampered": 0}
due = (
sess.query(ContentItem)
.filter(ContentItem.status == ContentStatus.APPROVED)
.all()
)
now = datetime.now(UTC)
for item in due:
if item.scheduled_for and item.scheduled_for > now:
continue # not due yet
# binding: caption must be exactly what was approved (audit finding)
if item.approved_hash and _content_hash(item.caption) != item.approved_hash:
audit(
sess,
actor="daemon:publisher",
action="publish_refused_hash_mismatch",
result="error",
detail={"item": item.id},
)
stats["tampered"] += 1
continue
try:
if item.channel == "nostr":
item.post_ref = _publish_nostr(item, cfg, sess, sender=nostr_sender)
else:
# disabled channel is TERMINAL (audit finding: this used to
# write a skip audit row every 5 minutes, forever)
item.status = ContentStatus.SKIPPED
item.error = f"{item.channel} adapter disabled by default"
audit(
sess,
actor="daemon:publisher",
action="publish_skipped_terminal",
detail={"item": item.id, "channel": item.channel},
)
stats["skipped"] += 1
continue
except Exception as exc: # noqa: BLE001 — publish boundary: audit and continue
item.attempts += 1
item.error = str(exc)[:300]
if item.attempts >= cfg.max_attempts:
# attempt budget exhausted -> terminal (no infinite retries)
item.status = ContentStatus.SKIPPED
audit(
sess,
actor="daemon:publisher",
action="publish_abandoned",
result="error",
detail={"item": item.id, "attempts": item.attempts},
)
else:
audit(
sess,
actor="daemon:publisher",
action="publish_failed",
result="error",
detail={"item": item.id, "error": item.error, "attempt": item.attempts},
)
stats["failed"] += 1
continue
item.status = ContentStatus.PUBLISHED
item.published_at = datetime.now(UTC)
audit(
sess,
actor="daemon:publisher",
action="publish_content",
detail={"item": item.id, "channel": item.channel, "ref": item.post_ref},
)
stats["published"] += 1
return stats
# --- CLI wrappers ---------------------------------------------------------------------
def plan(cfg: Config | None = None) -> int:
from cayceops.config import load
from cayceops.db import engine
cfg = cfg or load()
try:
with session_scope() as sess:
run_row = DaemonRun(daemon="publisher")
sess.add(run_row)
sess.flush()
stats = _plan(cfg, sess)
run_row.status = "ok"
run_row.detail = stats
run_row.finished_at = datetime.now(UTC)
return 0
finally:
engine().dispose()
def publish_due(cfg: Config | None = None) -> int:
from cayceops.config import load
from cayceops.db import engine
cfg = cfg or load()
try:
with session_scope() as sess:
run_row = DaemonRun(daemon="publisher")
sess.add(run_row)
sess.flush()
stats = _publish_due(cfg, sess)
run_row.status = "ok"
run_row.detail = stats
run_row.finished_at = datetime.now(UTC)
return 0
finally:
engine().dispose()
def make_image(item_id: int, cfg: Config | None = None) -> int:
from cayceops.config import load
from cayceops.db import engine
cfg = cfg or load()
try:
with session_scope() as sess:
item = sess.get(ContentItem, item_id)
if item is None:
return 1
print(_render_image(item, cfg))
return 0
finally:
engine().dispose()
def approve(item_id: int, actor: str) -> int:
from cayceops.db import engine
try:
with session_scope() as sess:
return _approve(item_id, actor, sess)
finally:
engine().dispose()
+1
View File
@@ -0,0 +1 @@
"""scanner package."""
+209
View File
@@ -0,0 +1,209 @@
"""scanner — opportunity discovery. Proposes; never executes.
Sources come from ``CAYCE_OPS_SCANNER_SOURCES`` (JSON array):
[{"type": "rss", "url": "https://example.org/feed.xml"},
{"type": "page", "url": "https://example.org/partners"}]
Every discovered candidate becomes a ``proposals`` row with status ``proposed``
and a stable ``dedupe_key`` (sha1 of url-or-title) so repeated scans stay
idempotent. The scanner performs no side effects beyond writing proposals and
audit entries.
"""
from __future__ import annotations
import hashlib
import json
import re
import urllib.error
import urllib.request
from datetime import UTC, datetime
from html.parser import HTMLParser
from xml.etree import ElementTree
from cayceops.config import Config
from cayceops.db import session_scope
from cayceops.models import DaemonRun, Proposal, ProposalSource, ProposalStatus, audit
# kind classification heuristics (checked in order)
_KIND_PATTERNS = [
("partnership", re.compile(r"partner|collaborat|integrat", re.I)),
("affiliate", re.compile(r"affiliate|referral|commission|revenue\s*share", re.I)),
]
DEFAULT_KIND = "content_gap"
class _LinkCollector(HTMLParser):
"""Minimal HTML parser: page <title> + anchor (href, text) pairs."""
def __init__(self) -> None:
super().__init__()
self.title: str = ""
self.links: list[tuple[str, str]] = []
self._in_title = False
self._in_anchor = False
self._href = ""
self._text: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str]]) -> None:
if tag == "title":
self._in_title = True
elif tag == "a":
self._in_anchor = True
self._href = dict(attrs).get("href", "")
self._text = []
def handle_endtag(self, tag: str) -> None:
if tag == "title":
self._in_title = False
elif tag == "a" and self._in_anchor:
self._in_anchor = False
text = " ".join("".join(self._text).split())
if self._href and text:
self.links.append((self._href, text))
def handle_data(self, data: str) -> None:
if self._in_title:
self.title += data
if self._in_anchor:
self._text.append(data)
def _fetch(url: str, cfg: Config, timeout: float = 20.0) -> bytes:
req = urllib.request.Request(
url, headers={"User-Agent": cfg.scanner_user_agent}, method="GET"
)
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
return resp.read()
def _parse_rss(body: bytes) -> list[tuple[str, str, str]]:
"""Return (title, link, description) tuples from an RSS/Atom feed."""
out: list[tuple[str, str, str]] = []
root = ElementTree.fromstring(body)
ns = {"atom": "http://www.w3.org/2005/Atom"}
for item in root.iter("item"): # RSS 2.0
title = item.findtext("title") or ""
link = item.findtext("link") or ""
desc = re.sub(r"<[^>]+>", " ", item.findtext("description") or "")
out.append((" ".join(title.split()), link, " ".join(desc.split())[:500]))
for entry in root.findall("atom:entry", ns): # Atom
title = entry.findtext("atom:title", namespaces=ns) or ""
link_el = entry.find("atom:link", ns)
link = link_el.get("href", "") if link_el is not None else ""
desc = re.sub(r"<[^>]+>", " ", entry.findtext("atom:summary", namespaces=ns) or "")
out.append((" ".join(title.split()), link, " ".join(desc.split())[:500]))
return out
def _parse_page(body: bytes) -> list[tuple[str, str, str]]:
"""Return (title=anchor text, link=href, description=page title) tuples."""
parser = _LinkCollector()
parser.feed(body.decode("utf-8", errors="replace"))
page_title = " ".join(parser.title.split())
return [(text, href, f"linked from page: {page_title}") for href, text in parser.links]
def _classify(title: str, description: str) -> str:
blob = f"{title}\n{description}"
for kind, pattern in _KIND_PATTERNS:
if pattern.search(blob):
return kind
return DEFAULT_KIND
def _score(title: str, description: str, keywords: list[str]) -> float:
blob = f"{title}\n{description}".lower()
hits = sum(1 for kw in keywords if kw.lower() in blob)
return round(min(hits / max(1, len(keywords)), 1.0), 3)
def scan(cfg: Config, sess, fetch=_fetch) -> dict:
"""Run one scan pass against all configured sources. Returns run stats."""
sources = json.loads(cfg.scanner_sources or "[]")
stats = {"sources": len(sources), "candidates": 0, "proposed": 0, "skipped_existing": 0}
existing = {
key
for (key,) in sess.query(Proposal.dedupe_key).filter(
Proposal.dedupe_key.isnot(None)
)
}
for src in sources:
url = src.get("url", "")
stype = src.get("type", "page")
if not url:
continue
try:
body = fetch(url, cfg)
except (urllib.error.URLError, OSError) as exc:
stats.setdefault("source_errors", []).append({"url": url, "error": str(exc)[:200]})
continue
items = _parse_rss(body) if stype == "rss" else _parse_page(body)
stats["candidates"] += len(items)
for title, link, desc in items:
text = title or link
if not text:
continue
keywords = cfg.scanner_keywords
score = _score(title or link, desc, keywords)
if score <= 0 and stype != "page":
continue # feeds must match at least one keyword; pages keep links
dedupe = hashlib.sha1((link or title).encode()).hexdigest()[:32] # noqa: S324
if dedupe in existing:
stats["skipped_existing"] += 1
continue
proposal = Proposal(
source=ProposalSource.SCANNER,
kind=_classify(text, desc),
title=text[:512],
url=(link or None)[:1024] if (link or None) else None,
detail={
"source_type": stype,
"source_url": url,
"summary": desc,
"scanned_at": datetime.now(UTC).isoformat(),
},
match_score=score,
status=ProposalStatus.PROPOSED,
dedupe_key=dedupe,
)
sess.add(proposal)
sess.flush()
audit(
sess,
actor="daemon:scanner",
action="propose",
proposal_id=proposal.id,
detail={"kind": proposal.kind, "score": score, "source": stype},
)
existing.add(dedupe)
stats["proposed"] += 1
return stats
def run(cfg: Config | None = None) -> int:
"""CLI entry: one scan pass, wrapped in a DaemonRun record."""
from cayceops.config import load
from cayceops.db import engine
cfg = cfg or load()
try:
with session_scope() as sess:
run_row = DaemonRun(daemon="scanner")
sess.add(run_row)
sess.flush()
try:
stats = scan(cfg, sess)
except Exception as exc: # noqa: BLE001 — daemon boundary: record and fail cleanly
run_row.status = "error"
run_row.detail = {"error": str(exc)[:300]}
run_row.finished_at = datetime.now(UTC)
return 1
run_row.status = "ok"
run_row.detail = stats
run_row.finished_at = datetime.now(UTC)
return 0
finally:
engine().dispose()
+172
View File
@@ -0,0 +1,172 @@
"""Cayce Ops — configuration.
All configuration comes from environment variables prefixed with ``CAYCE_OPS_``.
No config files, no defaults that touch the network. See .env.example for the
full list with placeholder values.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
def _bool(name: str, default: bool = False) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _int(name: str, default: int) -> int:
raw = os.environ.get(name)
if raw is None or not raw.strip():
return default
return int(raw)
def _list(name: str, default: list[str] | None = None) -> list[str]:
raw = os.environ.get(name)
if raw is None or not raw.strip():
return list(default or [])
return [item.strip() for item in raw.split(",") if item.strip()]
@dataclass(frozen=True)
class Config:
"""Snapshot of the process environment. Constructed once per CLI invocation."""
# --- core ---
database_url: str = field(
default_factory=lambda: os.environ.get(
"CAYCE_OPS_DATABASE_URL", "sqlite:///cayce-ops.db"
)
)
autonomous_mode: bool = field(
default_factory=lambda: _bool("CAYCE_OPS_AUTONOMOUS_MODE", False)
)
log_level: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_LOG_LEVEL", "INFO")
)
state_dir: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_STATE_DIR", ".")
)
media_dir: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_MEDIA_DIR", "./media")
)
# --- scanner ---
scanner_sources: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_SCANNER_SOURCES", "[]")
)
scanner_keywords: list[str] = field(
default_factory=lambda: _list(
"CAYCE_OPS_SCANNER_KEYWORDS",
["affiliate", "partnership", "bitcoin", "lightning", "travel"],
)
)
scanner_user_agent: str = field(
default_factory=lambda: os.environ.get(
"CAYCE_OPS_SCANNER_USER_AGENT", "cayce-ops-scanner/0.1"
)
)
# --- executor ---
autonomous_kinds: list[str] = field(
default_factory=lambda: _list("CAYCE_OPS_AUTONOMOUS_KINDS", [])
)
autonomous_daily_cap: int = field(
default_factory=lambda: _int("CAYCE_OPS_AUTONOMOUS_DAILY_CAP", 0)
)
max_attempts: int = field(
default_factory=lambda: _int("CAYCE_OPS_MAX_ATTEMPTS", 2)
)
enable_http_actions: bool = field(
default_factory=lambda: _bool("CAYCE_OPS_ENABLE_HTTP_ACTIONS", False)
)
# when set (64-char hex x-only pubkey), human approvals MUST carry a valid
# BIP-340 signature over "cayce-ops:approve:<id>" made by the operator key.
operator_pubkey: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_OPERATOR_PUBKEY", "")
)
# --- mail ---
imap_host: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_IMAP_HOST", "")
)
imap_port: int = field(default_factory=lambda: _int("CAYCE_OPS_IMAP_PORT", 993))
imap_user: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_IMAP_USER", "")
)
imap_password: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_IMAP_PASSWORD", "")
)
imap_mailbox: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_IMAP_MAILBOX", "INBOX")
)
smtp_host: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_SMTP_HOST", "")
)
smtp_port: int = field(default_factory=lambda: _int("CAYCE_OPS_SMTP_PORT", 587))
smtp_user: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_SMTP_USER", "")
)
smtp_password: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_SMTP_PASSWORD", "")
)
mail_from: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_MAIL_FROM", "")
)
# --- llm ---
llm_provider: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_LLM_PROVIDER", "null")
)
llm_model: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_LLM_MODEL", "")
)
llm_base_url: str = field(
default_factory=lambda: os.environ.get(
"CAYCE_OPS_LLM_BASE_URL", "https://api.openai.com/v1"
)
)
llm_api_key: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_LLM_API_KEY", "")
)
llm_review_threshold: float = field(
default_factory=lambda: float(
os.environ.get("CAYCE_OPS_LLM_REVIEW_THRESHOLD", "0.7")
)
)
# --- publisher / nostr ---
nostr_private_key: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_NOSTR_PRIVATE_KEY", "")
)
nostr_relays: list[str] = field(
default_factory=lambda: _list("CAYCE_OPS_NOSTR_RELAYS", [])
)
enable_facebook: bool = field(
default_factory=lambda: _bool("CAYCE_OPS_ENABLE_FACEBOOK", False)
)
facebook_page_token: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_FACEBOOK_PAGE_TOKEN", "")
)
enable_instagram: bool = field(
default_factory=lambda: _bool("CAYCE_OPS_ENABLE_INSTAGRAM", False)
)
instagram_user_id: str = field(
default_factory=lambda: os.environ.get("CAYCE_OPS_INSTAGRAM_USER_ID", "")
)
# --- monitor ---
monitor_output: str = field(
default_factory=lambda: os.environ.get(
"CAYCE_OPS_MONITOR_OUTPUT", "./status.html"
)
)
def load() -> Config:
"""Build the process Config snapshot."""
return Config()
+81
View File
@@ -0,0 +1,81 @@
"""Database engine, sessions and schema lifecycle helpers."""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session, sessionmaker
from cayceops.models import Base
_engine = None
_factory: sessionmaker | None = None
def engine(url: str | None = None):
"""Return (and cache) the process SQLAlchemy engine."""
global _engine
if _engine is None:
from cayceops.config import load
_engine = create_engine(url or load().database_url, future=True)
return _engine
def reset_engine() -> None:
"""Drop the cached engine (used by tests and CLI re-entry)."""
global _engine, _factory
if _engine is not None:
_engine.dispose()
_engine = None
_factory = None
def session_factory():
global _factory
if _factory is None:
_factory = sessionmaker(bind=engine(), expire_on_commit=False, future=True)
return _factory
@contextmanager
def session_scope() -> Iterator[Session]:
"""Transactional scope: commit on success, rollback on error."""
s = session_factory()()
try:
yield s
s.commit()
except Exception:
s.rollback()
raise
finally:
s.close()
def init_db() -> None:
"""Create all tables from metadata (dev/test convenience).
Production uses Alembic migrations (``cayceops db upgrade``).
"""
Base.metadata.create_all(engine())
def upgrade_db() -> None:
"""Run Alembic migrations to head."""
from alembic.config import Config as AlembicConfig
from alembic import command
from cayceops.config import load
cfg = AlembicConfig("alembic.ini")
cfg.set_main_option("script_location", "alembic")
cfg.set_main_option("sqlalchemy.url", load().database_url)
command.upgrade(cfg, "head")
def current_revision() -> str | None:
with engine().connect() as conn:
row = conn.execute(text("SELECT version_num FROM alembic_version")).first()
return row[0] if row else None
+80
View File
@@ -0,0 +1,80 @@
"""LLM provider interface — provider-agnostic by design.
The default provider is ``null`` (no network calls, no LLM). Components must
degrade gracefully when the provider is null: rule-based fallbacks kick in.
Adding a provider = implementing ``LLMProvider.complete`` and registering it in
``get_provider``. Provider and model are selected exclusively via env vars
(``CAYCE_OPS_LLM_PROVIDER``, ``CAYCE_OPS_LLM_MODEL``).
"""
from __future__ import annotations
import json
import urllib.request
from typing import Protocol
class LLMProvider(Protocol):
name: str
def complete(self, prompt: str, *, system: str = "", timeout: float = 30.0) -> str:
"""Return the model's completion for the prompt. Raises on failure."""
... # pragma: no cover
class NullProvider:
"""No-op provider: never touches the network. Signals 'no LLM available'."""
name = "null"
def complete(self, prompt: str, *, system: str = "", timeout: float = 30.0) -> str:
raise RuntimeError("LLM provider is 'null': no LLM configured")
class OpenAICompatProvider:
"""Minimal OpenAI-compatible /chat/completions client (stdlib urllib only).
Works with OpenAI, OpenRouter, local llama.cpp/vLLM servers, etc.
"""
name = "openai-compat"
def __init__(self, base_url: str, api_key: str, model: str) -> None:
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model
def complete(self, prompt: str, *, system: str = "", timeout: float = 30.0) -> str:
payload = {
"model": self.model,
"messages": (
([{"role": "system", "content": system}] if system else [])
+ [{"role": "user", "content": prompt}]
),
"temperature": 0.0,
}
req = urllib.request.Request(
f"{self.base_url}/chat/completions",
data=json.dumps(payload).encode(),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (base_url is operator-configured)
body = json.loads(resp.read().decode())
return body["choices"][0]["message"]["content"]
def get_provider(cfg=None) -> LLMProvider:
"""Build the provider named by CAYCE_OPS_LLM_PROVIDER (default: null)."""
from cayceops.config import load
cfg = cfg or load()
if cfg.llm_provider == "null":
return NullProvider()
if cfg.llm_provider == "openai-compat":
return OpenAICompatProvider(cfg.llm_base_url, cfg.llm_api_key, cfg.llm_model)
raise ValueError(f"unknown LLM provider: {cfg.llm_provider!r}")
+272
View File
@@ -0,0 +1,272 @@
"""ORM models and lifecycle states shared by every component.
Status machines
---------------
proposal: proposed -> approved -> running -> done | failed
proposed -> rejected
mail: (inbound) classified: approval|rejection|question|other|needs_review
(any) draft: none -> drafted -> approved -> sent | skipped
content: draft -> approved -> published | skipped (terminal)
Audit chain
-----------
audit_log is hash-chained: each entry stores sha256(prev_hash | fields).
``cayceops.models.verify_audit_chain`` re-walks it; the monitor surfaces breaks.
Not tamper-proof (DB writes can rewrite the chain) but tamper-EVIDENT against
selective edits. See AUDIT.md / audit response 2026-10-20.
"""
from __future__ import annotations
import enum
import hashlib
import json
from datetime import UTC, datetime
from sqlalchemy import JSON, DateTime, Enum, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
def utcnow() -> datetime:
return datetime.now(UTC)
def _content_hash(text: str) -> str:
"""Binding hash for human-approved text (drafts, captions)."""
return hashlib.sha256(text.encode("utf-8")).hexdigest()
class Base(DeclarativeBase):
pass
# --- proposals ---------------------------------------------------------------
class ProposalStatus(enum.StrEnum):
PROPOSED = "proposed"
APPROVED = "approved"
REJECTED = "rejected"
RUNNING = "running"
DONE = "done"
FAILED = "failed"
class ProposalSource(enum.StrEnum):
SCANNER = "scanner"
MANUAL = "manual"
class Proposal(Base):
__tablename__ = "proposals"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source: Mapped[ProposalSource] = mapped_column(
Enum(ProposalSource), default=ProposalSource.SCANNER
)
kind: Mapped[str] = mapped_column(String(64)) # affiliate|partnership|content_gap|other
title: Mapped[str] = mapped_column(String(512))
url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
detail: Mapped[dict] = mapped_column(JSON, default=dict)
# keyword-match relevance 0..1 — NOT risk. auto-approve takes highest first.
match_score: Mapped[float] = mapped_column(Float, default=0.0)
status: Mapped[ProposalStatus] = mapped_column(
Enum(ProposalStatus), default=ProposalStatus.PROPOSED, index=True
)
attempts: Mapped[int] = mapped_column(Integer, default=0)
dedupe_key: Mapped[str | None] = mapped_column(String(256), unique=True, nullable=True)
approved_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow
)
# --- audit (hash-chained) ----------------------------------------------------
class AuditEntry(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
actor: Mapped[str] = mapped_column(String(128)) # human | auto:policy | daemon:<name>
proposal_id: Mapped[int | None] = mapped_column(
ForeignKey("proposals.id", ondelete="SET NULL"), nullable=True, index=True
)
action: Mapped[str] = mapped_column(String(64)) # approve|reject|execute|classify|...
detail: Mapped[dict] = mapped_column(JSON, default=dict)
result: Mapped[str] = mapped_column(String(32), default="ok") # ok | error
# tamper-evident chain
prev_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
entry_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
def audit(
sess,
actor: str,
action: str,
proposal_id: int | None = None,
detail: dict | None = None,
result: str = "ok",
) -> AuditEntry:
"""Record one audited action, chained to the previous entry.
Chain: entry_hash = sha256(prev_hash | ts | actor | action | proposal_id | detail | result).
Every component writes through this helper.
"""
prev = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
prev_hash = prev.entry_hash if prev is not None else "GENESIS"
ts = utcnow()
# epoch int: identical after a sqlite/pg round-trip (tz-aware isoformat is not)
ts_int = int(ts.timestamp())
detail_json = json.dumps(detail or {}, sort_keys=True, ensure_ascii=False)
payload = "|".join(
[
prev_hash or "GENESIS",
str(ts_int),
actor,
action,
str(proposal_id),
detail_json,
result,
]
)
entry = AuditEntry(
ts=ts,
actor=actor,
action=action,
proposal_id=proposal_id,
detail=detail or {},
result=result,
prev_hash=prev_hash,
entry_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(),
)
sess.add(entry)
return entry
def verify_audit_chain(sess) -> tuple[bool, int | None]:
"""Re-walk the chain. Returns (ok, first_broken_entry_id or None)."""
prev_hash = "GENESIS"
for entry in sess.query(AuditEntry).order_by(AuditEntry.id).all():
detail_json = json.dumps(entry.detail or {}, sort_keys=True, ensure_ascii=False)
ts = entry.ts
if ts.tzinfo is None:
ts = ts.replace(tzinfo=UTC)
payload = "|".join(
[
prev_hash,
str(int(ts.timestamp())),
entry.actor,
entry.action,
str(entry.proposal_id),
detail_json,
entry.result,
]
)
expected = hashlib.sha256(payload.encode("utf-8")).hexdigest()
if entry.prev_hash != prev_hash or entry.entry_hash != expected:
return False, entry.id
prev_hash = entry.entry_hash
return True, None
# --- mail --------------------------------------------------------------------
class MailDirection(enum.StrEnum):
INBOUND = "in"
OUTBOUND = "out"
class MailClassification(enum.StrEnum):
APPROVAL = "approval"
REJECTION = "rejection"
QUESTION = "question"
OTHER = "other"
NEEDS_REVIEW = "needs_review"
class DraftStatus(enum.StrEnum):
NONE = "none"
DRAFTED = "drafted"
APPROVED = "approved" # human approved the draft; SMTP may send it
SENT = "sent"
SKIPPED = "skipped"
class MailMessage(Base):
__tablename__ = "mail_messages"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
direction: Mapped[MailDirection] = mapped_column(Enum(MailDirection))
message_id: Mapped[str | None] = mapped_column(String(998), nullable=True, index=True)
in_reply_to: Mapped[str | None] = mapped_column(String(998), nullable=True)
from_addr: Mapped[str | None] = mapped_column(String(320), nullable=True)
to_addr: Mapped[str | None] = mapped_column(String(320), nullable=True)
subject: Mapped[str | None] = mapped_column(String(998), nullable=True)
body: Mapped[str | None] = mapped_column(Text, nullable=True)
classification: Mapped[MailClassification | None] = mapped_column(
Enum(MailClassification), nullable=True
)
confidence: Mapped[float] = mapped_column(Float, default=0.0)
proposal_id: Mapped[int | None] = mapped_column(
ForeignKey("proposals.id", ondelete="SET NULL"), nullable=True, index=True
)
draft_status: Mapped[DraftStatus] = mapped_column(
Enum(DraftStatus), default=DraftStatus.NONE
)
draft_reply: Mapped[str | None] = mapped_column(Text, nullable=True)
# binding: hash of the draft text the human approved + who approved it
approved_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
approved_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# --- content -----------------------------------------------------------------
class ContentStatus(enum.StrEnum):
DRAFT = "draft"
APPROVED = "approved"
PUBLISHED = "published"
SKIPPED = "skipped" # terminal: disabled channel or attempt budget exhausted
class ContentItem(Base):
__tablename__ = "content_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
theme: Mapped[str] = mapped_column(String(128))
channel: Mapped[str] = mapped_column(String(32)) # nostr|facebook|instagram
caption: Mapped[str] = mapped_column(Text)
media_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
link: Mapped[str | None] = mapped_column(String(1024), nullable=True)
status: Mapped[ContentStatus] = mapped_column(
Enum(ContentStatus), default=ContentStatus.DRAFT, index=True
)
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
post_ref: Mapped[str | None] = mapped_column(String(512), nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
attempts: Mapped[int] = mapped_column(Integer, default=0)
# binding: hash of the caption the human approved
approved_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
# --- daemon runs (monitor input) ---------------------------------------------
class DaemonRun(Base):
__tablename__ = "daemon_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# scanner | executor | maild | publisher | monitor
daemon: Mapped[str] = mapped_column(String(32), index=True)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
status: Mapped[str] = mapped_column(String(16), default="ok") # ok | error
detail: Mapped[dict] = mapped_column(JSON, default=dict)
+22
View File
@@ -0,0 +1,22 @@
-- Per-daemon least-privilege roles (audit critical finding 1, deployment half).
-- The app-level chain + signatures are in code; this locks the DB layer.
-- Apply on the production Postgres; adjust the connection limits.
CREATE ROLE cayce_scanner LOGIN PASSWORD 'set-from-env';
CREATE ROLE cayce_executor LOGIN PASSWORD 'set-from-env';
CREATE ROLE cayce_maild LOGIN PASSWORD 'set-from-env';
CREATE ROLE cayce_publisher LOGIN PASSWORD 'set-from-env';
CREATE ROLE cayce_monitor LOGIN PASSWORD 'set-from-env';
GRANT SELECT ON proposals, audit_log, mail_messages, content_items, daemon_runs TO cayce_monitor;
GRANT SELECT, INSERT, UPDATE ON proposals TO cayce_scanner; -- no DELETE
GRANT INSERT ON audit_log TO cayce_scanner; -- append-only
GRANT SELECT, UPDATE ON proposals TO cayce_executor; -- status transitions
GRANT INSERT ON audit_log TO cayce_executor; -- append-only
GRANT SELECT, INSERT, UPDATE ON mail_messages, proposals TO cayce_maild;
GRANT INSERT ON audit_log TO cayce_maild;
GRANT SELECT, UPDATE ON content_items TO cayce_publisher;
GRANT INSERT ON audit_log TO cayce_publisher;
GRANT SELECT ON daemon_runs TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher;
-- sequences
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor;
+29
View File
@@ -0,0 +1,29 @@
# systemd deployment (VPS)
This is the primary deployment path (docker-compose is for local dev).
```bash
sudo useradd -r -m -d /srv/cayce-ops cayce
rsync -a --exclude .venv ./ cayce@vm:/srv/cayce-ops/
sudo -u cayce uv sync --frozen
sudo -u cayce cayceops db upgrade # alembic to head
sudo cp deploy/systemd/cayce-ops@.service deploy/systemd/cayce-ops@.timer /etc/systemd/system/
# Every daemon has a `run` subcommand now:
# scanner run · executor run · maild run (poll + send-approved)
# publisher run (publish-due) · monitor run (build)
# cadence per daemon (edit OnCalendar in each timer copy):
# scanner hourly executor */10 min maild */5 min
# publisher */5 min monitor */1 min
# NOTE: `publisher plan` (fills the calendar) is a SEPARATE timer:
# OnCalendar=daily -> ExecStart=/srv/cayce-ops/.venv/bin/cayceops publisher plan
for d in scanner executor maild publisher monitor; do
sudo cp deploy/systemd/cayce-ops@.timer /etc/systemd/system/cayce-ops@$d.timer
done
# adjust OnCalendar per timer, then:
sudo systemctl daemon-reload
sudo systemctl enable --now cayce-ops@{scanner,executor,maild,publisher,monitor}.timer
```
The unit hardens the daemon (NoNewPrivileges, ProtectSystem=strict) and reads
secrets only from `/srv/cayce-ops/.env` (never in the repo).
+19
View File
@@ -0,0 +1,19 @@
# Unit template — the same file is instantiated per daemon with different ExecStart.
# Deploy: cp deploy/systemd/cayce-ops@.service /etc/systemd/system/
# systemctl enable --now cayce-ops@scanner.timer
[Unit]
Description=cayce-ops %i daemon
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=oneshot
User=cayce
WorkingDirectory=/srv/cayce-ops
EnvironmentFile=/srv/cayce-ops/.env
ExecStart=/srv/cayce-ops/.venv/bin/cayceops %i run
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/srv/cayce-ops
+15
View File
@@ -0,0 +1,15 @@
# Timer template — instantiated per daemon.
# systemctl enable --now cayce-ops@scanner.timer
# Cadence per daemon: scanner 1h, executor 10m, maild 5m, publisher 5m, monitor 1m.
# Copy and edit the OnCalendar line per instance (see deploy/systemd/README.md).
[Unit]
Description=Run cayce-ops %i periodically
[Timer]
OnCalendar=*:0/10
RandomizedDelaySec=30
Persistent=true
[Install]
WantedBy=timers.target
+74
View File
@@ -0,0 +1,74 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: cayce
POSTGRES_PASSWORD: localdev-only-not-a-secret
POSTGRES_DB: cayce_ops
ports:
- "127.0.0.1:5432:5432" # loopback only — Docker bypasses host firewalls
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U cayce -d cayce_ops"]
interval: 5s
timeout: 3s
retries: 10
# One container per daemon; all run the same image and differ only in command.
# Cron-style cadence via `sleep` loop; on a VPS prefer the systemd timers in
# deploy/systemd/ (they are the primary deployment path).
scanner:
build: .
command: sh -c "while true; do cayceops scanner run; sleep 3600; done"
env_file: .env
environment:
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:localdev-only-not-a-secret@db:5432/cayce_ops
depends_on:
db:
condition: service_healthy
executor:
build: .
command: sh -c "while true; do cayceops executor run; sleep 600; done"
env_file: .env
environment:
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:localdev-only-not-a-secret@db:5432/cayce_ops
depends_on:
db:
condition: service_healthy
maild:
build: .
command: sh -c "while true; do cayceops maild poll && cayceops maild send-approved; sleep 300; done"
env_file: .env
environment:
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:localdev-only-not-a-secret@db:5432/cayce_ops
depends_on:
db:
condition: service_healthy
publisher:
build: .
command: sh -c "while true; do cayceops publisher publish-due; sleep 300; done"
env_file: .env
environment:
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:localdev-only-not-a-secret@db:5432/cayce_ops
depends_on:
db:
condition: service_healthy
monitor:
build: .
command: sh -c "while true; do cayceops monitor build; sleep 60; done"
environment:
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:localdev-only-not-a-secret@db:5432/cayce_ops
CAYCE_OPS_MONITOR_OUTPUT: /srv/status.html
volumes:
- ./status:/srv
depends_on:
db:
condition: service_healthy
volumes:
pgdata:
+97
View File
@@ -0,0 +1,97 @@
# Architectural decisions
Each entry: decision, why, and what it costs us. Newest last.
## D1 — uv + uv.lock as the lockfile
`uv` manages the pinned interpreter (3.12) and produces `uv.lock`. CI installs
with `uv sync --frozen`; nothing runs against unpinned trees.
*Cost:* contributors need uv (single static binary).
## D2 — Tests on SQLite; migrations authoritative for Postgres
Tests run on file-backed SQLite via `metadata.create_all` (fast, no service
dependency). The Alembic chain is the production schema path; `make
migrate-check` (CI job) smoke-runs `alembic upgrade head` on SQLite.
*Cost:* Postgres-specific features (e.g. JSONB, partial indexes) stay unused —
the schema uses portable `sa.JSON` instead.
## D3 — LLM behind an in-repo provider interface; null by default
`cayceops.llm.LLMProvider` is the only seam. `null` provider raises — components
fall back to deterministic rules. `openai-compat` uses stdlib `urllib` against
any OpenAI-shaped endpoint; no SDK dependency.
*Cost:* no retries/streaming/tool-calls — acceptable for classification and
draft drafting. Add providers by implementing `complete()`.
## D4 — Nostr client implemented in-repo (coincurve + websockets)
NIP-01 event build/sign/publish is ~150 auditable lines vs. a black-box SDK.
Keys accepted as 32-byte hex only. Relays are plain env config; publish is
fire-and-wait with a timeout; the event id is stored as `post_ref`.
*Cost:* no NIP-05, no retries across relay pools — fine for kind-1 notes.
## D5 — AUTONOMOUS_MODE semantics (the only non-human approval path)
Default **false**: only human-approved proposals execute, only
human-approved drafts are sendable/publishable.
**true** adds exactly one capability: the executor may auto-approve
`proposed` items whose `kind` is in `CAYCE_OPS_AUTONOMOUS_KINDS`, up to
`CAYCE_OPS_AUTONOMOUS_DAILY_CAP` per day, recording actor `auto:policy` in the
audit log. Everything else still requires a human. Auto-approval never touches
mail drafts or content items.
*Cost:* two code paths; both audited; the autonomous one is off by default and
cap-bounded.
## D6 — CI workflow files ship now; enforcement gate is `make check` until a runner exists
`.gitea/workflows/ci.yml` (Gitea Actions) and `.github/workflows/ci.yml`
(identical) run ruff, alembic smoke, pytest, gitleaks on every PR/push. The
current host runs no Gitea Actions runner, so until one is provisioned the
same gate is enforced locally: `scripts/pre-push` runs `make check` (lint +
tests + migration smoke + full-history secret scan) before every push, and the
audited commit hash in `AUDIT.md` is the one that passed it.
*Cost:* CI results are deferred on the current host; the gate itself is
identical to CI.
## D7 — gitleaks allowlist for test fixtures
`tests/` contains invented data (fake Nostr key, fake merchant mail). It is
allowlisted in `.gitleaks.toml` so the history scan stays signal-only.
*Cost:* a real secret committed under `tests/` would not trip the scanner —
compensating control: fixtures are reviewed in PRs, and no fixture ever reads
the environment.
## D8 — Proposal history kept when retiring the grant text
The repo began as a grant proposal; the retirement commit replaces it with the
project README (no grant references in the tree). The old commits stay — the
audit story ("this started as X, the operator mandated Y, here is the diff") is
more honest than a rewritten birth.
*Cost:* `git log` still shows the word "OpenSats" in old messages. The tree and
README carry none.
## D9 — Executor plugins are in-process registrations
An action plugin is a Python function registered in
`cayceops.components.executor.plugins.REGISTRY`, mapped by proposal `kind`.
`http_post` exists but is inert unless `CAYCE_OPS_ENABLE_HTTP_ACTIONS=true`.
*Cost:* no sandboxing of plugins — plugins are repo code, reviewed like
everything else.
## D10 — Audit chain is hash-chained; approval can require operator signature (post-audit)
`audit_log` rows chain sha256(prev_hash|fields); `verify_audit_chain` walks it,
the monitor can surface breaks. With `CAYCE_OPS_OPERATOR_PUBKEY` set,
`executor approve` REQUIRES a BIP-340 signature over
sha256("cayce-ops:approve:<id>") from the operator key — `--actor` becomes a
label, the key is the proof (the audit's "anyone can sign as anyone").
*Cost:* without a pubkey configured the gate degrades to the old free-text
actor; separate DB roles per daemon remain a deployment TODO (SQL to tighten
ships in deploy/roles.sql).
## D11 — Mail threading now has a real first-contact path (post-audit)
Sends create an OUTBOUND row (own Message-ID, In-Reply-To -> inbound);
inbound rows keep their original Message-ID. `maild compose` creates the
initial outreach draft for a proposal. Approval is bound to the draft text
via sha256 (`approved_hash`): an edited draft refuses to send until
re-approved. Each send commits in its own transaction — SMTP failure on
message N can no longer resurrect 1..N-1 as duplicates.
## D12 — IMAP \\Seen only after commit; content gates tighten (post-audit)
Messages are fetched unmarked and flagged \\Seen after the processing
transaction commits (crash mid-batch no longer loses mail). Publisher:
caption hash-bound at approval, disabled channels are terminal (`SKIPPED`),
publish failures burn a bounded attempt budget before abandonment.
`risk_score` renamed `match_score` (keyword relevance) and auto-approval
takes the HIGHEST score first.
+45
View File
@@ -0,0 +1,45 @@
[project]
name = "cayce-ops"
version = "0.1.0"
description = "Cayce autonomous operations stack: scanner, executor, mail-negotiator, publisher, monitor"
authors = [{ name = "Cayce", email = "cayce@cayce-co.xyz" }]
license = { text = "MIT" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"sqlalchemy==2.0.44",
"alembic==1.17.0",
"typer==0.20.0",
"psycopg[binary]==3.2.13",
"pillow==12.0.0",
"coincurve==21.0.0",
"websockets==15.0.1",
]
[project.scripts]
cayceops = "cayceops.cli:app"
[dependency-groups]
dev = [
"pytest==9.0.2",
"ruff==0.15.1",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["cayceops"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "W"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
filterwarnings = ["ignore::DeprecationWarning"]
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# The gate, wired as a real hook (audit medium finding: it did not self-install).
# Runs the exact CI pipeline; refuses the push on any failure.
set -e
cd "$(git rev-parse --show-toplevel)"
make check
echo "pre-push gate passed — safe to push"
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# Pre-push gate: same checks CI would run. Refuses to push if anything fails.
set -e
cd "$(git rev-parse --show-toplevel)"
make check
echo "pre-push gate passed — safe to push"
+77
View File
@@ -0,0 +1,77 @@
"""Shared pytest fixtures.
Tests run against a throwaway SQLite database (fast, zero external services).
The production path is Postgres via Alembic; ``make migrate-check`` smoke-tests
the migration chain on SQLite in CI.
All fixture data is invented — never real merchant or account data.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
# Config must see the test DB before cayceops.config/db get imported.
os.environ.setdefault("CAYCE_OPS_DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("CAYCE_OPS_AUTONOMOUS_MODE", "false")
@pytest.fixture()
def db_engine(tmp_path: Path):
"""A fresh schema per test, on a file-backed SQLite DB (shared in-process)."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cayceops.db import reset_engine
from cayceops.models import Base
url = f"sqlite:///{tmp_path}/test.db"
eng = create_engine(url, future=True, connect_args={"check_same_thread": False})
Base.metadata.create_all(eng)
factory = sessionmaker(bind=eng, expire_on_commit=False, future=True)
# point the app at the same engine/session factory
reset_engine()
import cayceops.db as appdb
appdb._engine = eng
appdb._factory = factory
yield eng
eng.dispose()
reset_engine()
@pytest.fixture()
def sess(db_engine):
"""A session bound to the fresh schema (rolls back at test end)."""
import cayceops.db as appdb
s = appdb.session_factory()()
yield s
s.rollback()
s.close()
@pytest.fixture()
def cfg(monkeypatch):
"""A Config snapshot with test defaults (no network, no autonomy)."""
from cayceops.config import Config
defaults = {
"CAYCE_OPS_DATABASE_URL": "sqlite:///:memory:",
"CAYCE_OPS_LLM_PROVIDER": "null",
"CAYCE_OPS_ENABLE_FACEBOOK": "false",
"CAYCE_OPS_ENABLE_INSTAGRAM": "false",
"CAYCE_OPS_NOSTR_RELAYS": "",
"CAYCE_OPS_MEDIA_DIR": "media",
}
def _apply(**overrides):
merged = {**defaults, **{k: str(v) for k, v in overrides.items()}}
for k, v in merged.items():
monkeypatch.setenv(k, v)
return Config()
return _apply
+23
View File
@@ -0,0 +1,23 @@
"""CLI smoke tests."""
from __future__ import annotations
from typer.testing import CliRunner
from cayceops import __version__
from cayceops.cli import app
runner = CliRunner()
def test_version():
result = runner.invoke(app, ["version"])
assert result.exit_code == 0
assert __version__ in result.output
def test_help_lists_components():
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
for sub in ("scanner", "executor", "maild", "publisher", "monitor", "db"):
assert sub in result.output
+187
View File
@@ -0,0 +1,187 @@
"""executor tests — human gate, autonomy bounds, retries, audit chain."""
from __future__ import annotations
import pytest
from cayceops.components.executor import executor, plugins
from cayceops.models import AuditEntry, Proposal, ProposalStatus
def _proposal(kind="content_gap", title="Test item", **kw):
return Proposal(kind=kind, title=title, **kw)
def test_approve_requires_proposed_state(cfg, sess):
p = _proposal()
sess.add(p)
sess.flush()
assert executor.approve(p.id, "gustavo", cfg(), sess) == 0
# second approve must fail (state is now approved)
assert executor.approve(p.id, "gustavo", cfg(), sess) == 1
assert p.approved_by == "gustavo"
assert p.status == ProposalStatus.APPROVED
def test_reject_records_actor(cfg, sess):
p = _proposal()
sess.add(p)
sess.flush()
assert executor.reject(p.id, "gustavo", cfg(), sess) == 0
assert p.status == ProposalStatus.REJECTED
def test_signed_approval_required_when_operator_key_configured(cfg, sess):
"""Critical finding 1: --actor was free text; now a key can be required."""
from coincurve import PrivateKey, PublicKey
from cayceops.components.executor import executor
secret = bytes.fromhex("22" * 32)
pubkey = PublicKey.from_secret(secret).format().hex()[2:]
p = _proposal()
sess.add(p)
sess.flush()
config = cfg(CAYCE_OPS_OPERATOR_PUBKEY=pubkey)
# no signature -> refused
assert executor.approve(p.id, "gustavo", config, sess, signature=None) == 2
# wrong key -> refused
bad = (
PrivateKey(bytes.fromhex("33" * 32))
.sign_schnorr(executor.approval_message(p.id), aux_randomness=b"\x00" * 32)
.hex()
)
assert executor.approve(p.id, "gustavo", config, sess, signature=bad) == 2
# correct key -> approved
good = (
PrivateKey(secret)
.sign_schnorr(executor.approval_message(p.id), aux_randomness=b"\x00" * 32)
.hex()
)
assert executor.approve(p.id, "gustavo", config, sess, signature=good) == 0
assert p.status is ProposalStatus.APPROVED
entry = sess.query(AuditEntry).filter_by(action="approve", result="ok").one()
assert "signature" in entry.detail
def test_auto_approve_orders_by_relevance_desc(cfg, sess):
"""Medium finding: ascending 'risk_score' approved noise first."""
low = _proposal(kind="content_gap", title="barely relevant")
low.match_score = 0.1
high = _proposal(kind="content_gap", title="highly relevant")
high.match_score = 0.9
sess.add_all([low, high])
sess.flush()
config = cfg(
CAYCE_OPS_AUTONOMOUS_MODE="true",
CAYCE_OPS_AUTONOMOUS_KINDS="content_gap",
CAYCE_OPS_AUTONOMOUS_DAILY_CAP="1",
)
executor._auto_approve(config, sess)
assert high.status is ProposalStatus.APPROVED
assert low.status is ProposalStatus.PROPOSED
def test_run_executes_only_approved(cfg, sess):
approved = _proposal(title="Approved item")
proposed = _proposal(title="Proposed item")
sess.add_all([approved, proposed])
sess.flush()
executor.approve(approved.id, "gustavo", cfg(), sess)
config = cfg() # AUTONOMOUS_MODE=false
assert executor.run(config, sess) == 0
assert approved.status == ProposalStatus.DONE
assert proposed.status == ProposalStatus.PROPOSED # untouched
acts = [a.action for a in sess.query(AuditEntry).order_by(AuditEntry.id).all()]
assert "execute_start" in acts and "execute_done" in acts
assert acts.count("approve") == 1
def test_autonomy_off_by_default(cfg, sess):
p = _proposal(kind="content_gap", title="Never auto-runs")
sess.add(p)
sess.flush()
config = cfg() # autonomous_mode False even with kinds configured
executor._auto_approve(config, sess)
assert p.status == ProposalStatus.PROPOSED
def test_autonomy_respects_kinds_and_daily_cap(cfg, sess):
p1 = _proposal(kind="content_gap", title="auto ok")
p2 = _proposal(kind="content_gap", title="auto over cap")
p3 = _proposal(kind="affiliate", title="kind not allowed")
sess.add_all([p1, p2, p3])
sess.flush()
config = cfg(
CAYCE_OPS_AUTONOMOUS_MODE="true",
CAYCE_OPS_AUTONOMOUS_KINDS="content_gap",
CAYCE_OPS_AUTONOMOUS_DAILY_CAP="1",
)
approved = executor._auto_approve(config, sess)
assert approved == 1
assert p1.status == ProposalStatus.APPROVED
assert p2.status == ProposalStatus.PROPOSED # cap reached
assert p3.status == ProposalStatus.PROPOSED # kind not in policy
actor = sess.query(AuditEntry).filter_by(action="approve").one().actor
assert actor == "auto:policy"
def test_failing_plugin_retries_then_fails(cfg, sess, monkeypatch):
p = _proposal(kind="affiliate", title="Will fail")
sess.add(p)
sess.flush()
executor.approve(p.id, "gustavo", cfg(), sess)
calls = {"n": 0}
def boom(proposal, cfg, sess):
calls["n"] += 1
raise RuntimeError("upstream exploded")
monkeypatch.setitem(plugins.REGISTRY, "affiliate", boom)
config = cfg(CAYCE_OPS_MAX_ATTEMPTS="2")
executor.run(config, sess) # attempt 1 -> back to approved for retry
assert p.status == ProposalStatus.APPROVED
assert p.attempts == 1
executor.run(config, sess) # attempt 2 -> failed for good
assert p.status == ProposalStatus.FAILED
assert p.attempts == 2
assert calls["n"] == 2
errors = sess.query(AuditEntry).filter_by(result="error").all()
assert any(a.action == "fail" for a in errors)
def test_http_post_blocked_when_disabled(cfg, sess):
p = _proposal(kind="affiliate", title=" webhook target")
p.detail = {"webhook_url": "https://hooks.example.org/ingest"}
sess.add(p)
sess.flush()
config = cfg() # CAYCE_OPS_ENABLE_HTTP_ACTIONS=false
with pytest.raises(PermissionError):
plugins.http_post(p, config, sess)
def test_unknown_kind_falls_back_to_log_action(cfg, sess):
p = _proposal(kind="brand_new_kind", title="No plugin yet")
sess.add(p)
sess.flush()
executor.approve(p.id, "gustavo", cfg(), sess)
assert executor.run(cfg(), sess) == 0
assert p.status == ProposalStatus.DONE
done = sess.query(AuditEntry).filter_by(action="execute_done").one()
assert done.detail["outcome"]["plugin_fallback"] is True
+351
View File
@@ -0,0 +1,351 @@
"""maild tests — parsing, classification, threading, gated send. Invented data only."""
from __future__ import annotations
from types import SimpleNamespace
from cayceops.components.maild import classify as clf
from cayceops.components.maild import maild
from cayceops.models import (
AuditEntry,
DraftStatus,
MailClassification,
MailDirection,
MailMessage,
Proposal,
ProposalStatus,
)
OUTBOUND_MSGID = "<cayce-draft-1-1000@cayce-co.xyz>"
STUB_CFG = SimpleNamespace(mail_from="agent@cayce-co.xyz")
def _eml(subject, body, in_reply_to=None, msgid=None) -> bytes:
lines = [
"From: Partner Desk <partners@acme.example.org>",
"To: agent@cayce-co.xyz",
f"Subject: {subject}",
f"Message-ID: {msgid or '<acme-reply-1@acme.example.org>'}",
]
if in_reply_to:
lines.append(f"In-Reply-To: {in_reply_to}")
lines += ["MIME-Version: 1.0", "Content-Type: text/plain; charset=utf-8", "", body]
return "\r\n".join(lines).encode()
def _proposal_with_sent_draft(sess) -> Proposal:
"""A proposal whose first-contact mail went through the REAL path:
compose -> approve -> send (creating a true OUTBOUND row)."""
p = Proposal(
kind="partnership",
title="Acme hotel affiliate program",
status=ProposalStatus.APPROVED,
)
sess.add(p)
sess.flush()
mail_id = maild._compose(
p.id, "partners@acme.example.org", "[cayce] partnership inquiry", "Hello!", sess
)
assert maild._approve_draft(mail_id, "gustavo", sess) == 0
assert maild._send_one(STUB_CFG, sess, mail_id, sender=lambda *a, **k: None) == "sent"
out = (
sess.query(MailMessage)
.filter_by(direction=MailDirection.OUTBOUND, proposal_id=p.id)
.one()
)
out.message_id = OUTBOUND_MSGID # deterministic id for the fixtures
sess.flush()
return p
def _fetcher(raws: list[bytes]):
return lambda cfg: [(bytes(str(i), "ascii"), raw) for i, raw in enumerate(raws)]
# --- classifier ---------------------------------------------------------------
def test_rules_classification(cfg):
config = cfg()
assert (
clf.classify("Your application was approved, welcome to the program", config)[0]
is MailClassification.APPROVAL
)
assert (
clf.classify("Unfortunately we cannot accept new partners", config)[0]
is MailClassification.REJECTION
)
assert (
clf.classify("Could you provide your traffic numbers?", config)[0]
is MailClassification.QUESTION
)
kind, _ = clf.classify("grocery list", config)
assert kind is MailClassification.OTHER or kind is MailClassification.NEEDS_REVIEW
def test_negated_approvals_are_rejections(cfg):
"""Critical finding 4: 'cannot accept'/'not approved' read as approvals."""
config = cfg()
cases = [
"We cannot accept your application",
"We are not accepting new partners at this time",
"Your application was not approved",
"We can't approve this request",
"We do not accept external affiliates",
]
for text in cases:
kind, confidence = clf.classify(text, config)
assert kind is MailClassification.REJECTION, f"{text!r} -> {kind}"
assert confidence >= 0.9
def test_llm_prompt_format_survives_json_braces():
"""Critical finding 4: .format() raised KeyError on the JSON sample."""
formatted = clf._LLM_PROMPT.format(body="x")
assert '{"classification"' in formatted # literal braces survive
def test_low_confidence_becomes_needs_review(cfg):
config = cfg(CAYCE_OPS_LLM_REVIEW_THRESHOLD="0.95")
kind, confidence = clf.classify("Your application was approved, welcome", config)
assert kind is MailClassification.NEEDS_REVIEW
assert confidence < 0.95
# --- inbound pass ---------------------------------------------------------------
def test_poll_threads_approval_and_drafts_reply(cfg, sess):
p = _proposal_with_sent_draft(sess)
raw = _eml(
"Re: [cayce] partnership inquiry",
"Approved! Welcome to the Acme partner program.",
in_reply_to=OUTBOUND_MSGID,
)
stats, nums = maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
assert stats["processed"] == 1 and stats["drafted"] == 1
assert len(nums) == 1
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
assert inbound.classification is MailClassification.APPROVAL
assert inbound.proposal_id == p.id
assert inbound.draft_status is DraftStatus.DRAFTED
assert inbound.draft_reply and "approval" in inbound.draft_reply.lower()
assert p.detail["merchant_status"] == "approval"
assert p.status is ProposalStatus.APPROVED # lifecycle untouched by maild
def test_poll_idempotent_on_message_id(cfg, sess):
_proposal_with_sent_draft(sess)
raw = _eml("Re: hi", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
first, second = maild._poll(cfg(), sess, fetcher=_fetcher([raw])), maild._poll(
cfg(), sess, fetcher=_fetcher([raw])
)
assert first[0]["processed"] == 1
assert second[0]["skipped"] == 1
def test_unthreaded_approval_needs_review(cfg, sess):
raw = _eml("Great news", "Approved! Welcome aboard.") # no In-Reply-To
stats, _ = maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
assert inbound.classification is MailClassification.NEEDS_REVIEW
assert stats["needs_review"] == 1
assert inbound.draft_status is DraftStatus.NONE # no draft for review items
def test_question_gets_clarification_draft(cfg, sess):
_proposal_with_sent_draft(sess)
raw = _eml("Re: inquiry", "Could you provide your monthly traffic?", in_reply_to=OUTBOUND_MSGID)
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
assert inbound.classification is MailClassification.QUESTION
assert inbound.draft_status is DraftStatus.DRAFTED
assert "traffic" in inbound.draft_reply
# --- human gate -------------------------------------------------------------------
def test_send_blocked_without_approval(cfg, sess):
_proposal_with_sent_draft(sess)
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
sent = []
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
stats = maild._send_approved(config, sess, sender=lambda *a, **k: sent.append(a))
assert stats["sent"] == 0 and sent == []
assert stats["blocked"] + stats["missing"] >= 0 # no crash paths
assert config.mail_from == "agent@cayce-co.xyz"
row = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
assert row.draft_status is DraftStatus.DRAFTED
def test_send_requires_explicit_human_approval(cfg, sess):
_proposal_with_sent_draft(sess)
raw = _eml(
"Re: inquiry",
"Approved, welcome to the program.",
in_reply_to=OUTBOUND_MSGID,
)
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
original_id = inbound.message_id
assert maild._approve_draft(inbound.id, "gustavo", sess) == 0
sent = []
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
def sender(cfg, to, subject, body, message_id, in_reply_to=None):
sent.append((to, subject, body, message_id, in_reply_to))
stats = maild._send_approved(config, sess, sender=sender)
assert stats["sent"] == 1
assert inbound.draft_status is DraftStatus.SENT
to, subject, body, message_id, in_reply_to = sent[0]
assert to == "partners@acme.example.org"
assert in_reply_to == original_id # In-Reply-To threads to the inbound mail
# a real OUTBOUND row now exists (audit fix: they were never created)
out = (
sess.query(MailMessage)
.filter_by(direction=MailDirection.OUTBOUND, message_id=message_id)
.one()
)
assert out.in_reply_to == original_id
assert out.proposal_id == inbound.proposal_id
assert inbound.message_id == original_id # inbound id NOT overwritten
row = (
sess.query(AuditEntry)
.filter_by(action="send_draft")
.order_by(AuditEntry.id.desc())
.first()
)
assert row is not None and "gustavo" in str(row.detail)
def test_double_approve_is_rejected(cfg, sess):
_proposal_with_sent_draft(sess)
raw = _eml("Re: inquiry", "Approved, welcome.", in_reply_to=OUTBOUND_MSGID)
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
assert maild._approve_draft(inbound.id, "gustavo", sess) == 0
assert maild._approve_draft(inbound.id, "gustavo", sess) == 1
# --- audit corrections ----------------------------------------------------------
def test_send_failure_does_not_resurrect_sent_mails(cfg, sess):
"""Critical finding 3: SMTP failure mid-batch rolled back the whole
transaction, re-sending already-delivered mails 5 minutes later."""
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
for i in range(3):
# unique first-contact thread per proposal (real compose -> send path)
p = Proposal(kind="partnership", title=f"Acme partner {i}")
sess.add(p)
sess.flush()
mail_id = maild._compose(
p.id, f"desk{i}@acme.example.org", f"[cayce] inquiry {i}", "Hello!", sess
)
assert maild._approve_draft(mail_id, "gustavo", sess) == 0
assert maild._send_one(STUB_CFG, sess, mail_id, sender=lambda *a, **k: None) == "sent"
sess.flush()
out = sess.get(MailMessage, mail_id)
raw = _eml(
f"Re: {i}",
"Approved, welcome to the program.",
in_reply_to=out.message_id,
msgid=f"<acme-{i}@acme.example.org>",
)
maild._poll(config, sess, fetcher=_fetcher([raw]))
inbound = (
sess.query(MailMessage)
.filter_by(direction=MailDirection.INBOUND, message_id=f"<acme-{i}@acme.example.org>")
.one()
)
maild._approve_draft(inbound.id, "gustavo", sess)
sess.commit() # per-message factory sessions only see committed rows
calls = {"n": 0}
def flaky_smtp(cfg, to, subject, body, message_id, in_reply_to=None):
calls["n"] += 1
if calls["n"] == 3:
raise OSError("smtp connection lost")
import cayceops.db as appdb
stats = maild._send_approved(
config, sess, sender=flaky_smtp, factory=appdb.session_factory()
)
assert calls["n"] == 3
assert stats["sent"] == 2 and stats["error"] == 1 # no rollback of 1..2
still_approved = (
sess.query(MailMessage)
.filter_by(direction=MailDirection.INBOUND, draft_status=DraftStatus.APPROVED)
.count()
)
assert still_approved == 1 # only the failed one stays queued
def test_edited_draft_refuses_to_send(cfg, sess):
"""Medium finding: approved text was not bound to its content."""
_proposal_with_sent_draft(sess)
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
assert maild._approve_draft(inbound.id, "gustavo", sess) == 0
inbound.draft_reply = "TOTALLY EDITED TEXT after approval" # tamper
sent = []
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
stats = maild._send_approved(config, sess, sender=lambda *a, **k: sent.append(a))
assert stats["sent"] == 0 and stats["hash_mismatch"] == 1
assert sent == []
assert inbound.draft_status is DraftStatus.DRAFTED # needs fresh approval
action = sess.query(AuditEntry).filter_by(action="send_refused_hash_mismatch").one()
assert action.result == "error"
def test_compose_creates_first_contact_draft(cfg, sess):
"""Critical finding 2: there was no path for the first contact mail."""
p = Proposal(kind="partnership", title="New hotel partner")
sess.add(p)
sess.flush()
mail_id = maild._compose(
p.id, "desk@hotel.example.org", "Partnership", "We would like to...", sess
)
assert mail_id > 0
out = sess.get(MailMessage, mail_id)
assert out.direction is MailDirection.OUTBOUND
assert out.draft_status is DraftStatus.DRAFTED
assert out.proposal_id == p.id
# approve + send -> an inbound reply can now thread to this proposal
assert maild._approve_draft(mail_id, "gustavo", sess) == 0
stats = maild._send_approved(STUB_CFG, sess, sender=lambda *a, **k: None)
assert stats["sent"] == 1
sess.flush()
reply = _eml("Re: Partnership", "Approved, welcome!", in_reply_to=out.message_id)
assert maild._match_proposal(maild.parse_raw(reply), sess) is p
def test_mark_seen_returned_for_post_commit_processing(cfg, sess):
"""Medium finding: \\\\Seen was set before processing (crash lost mail)."""
raw = _eml("Re: hi", "Approved, welcome to the program.")
stats, nums = maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
assert stats["processed"] == 1
assert nums == [b"0"] # caller marks seen only after commit
+112
View File
@@ -0,0 +1,112 @@
"""Schema-level tests: models persist, enums hold, audit helper writes."""
from __future__ import annotations
from datetime import UTC, datetime
import sqlalchemy as sa
from sqlalchemy import select
from cayceops.models import (
AuditEntry,
ContentItem,
DaemonRun,
MailMessage,
Proposal,
ProposalSource,
ProposalStatus,
audit,
)
def test_proposal_defaults(sess):
p = Proposal(kind="affiliate", title="Test merchant program")
sess.add(p)
sess.flush()
assert p.id is not None
assert p.status == ProposalStatus.PROPOSED
assert p.source == ProposalSource.SCANNER
assert p.attempts == 0
assert p.created_at.tzinfo is not None
def test_proposal_status_transitions(sess):
p = Proposal(kind="content_gap", title="Guide gap: Rio")
sess.add(p)
sess.flush()
p.status = ProposalStatus.APPROVED
p.approved_by = "gustavo"
p.approved_at = datetime.now(UTC)
p.status = ProposalStatus.RUNNING
p.status = ProposalStatus.DONE
sess.flush()
loaded = sess.scalar(select(Proposal).where(Proposal.id == p.id))
assert loaded.status == ProposalStatus.DONE
assert loaded.approved_by == "gustavo"
def test_dedupe_key_unique(sess):
sess.add(Proposal(kind="affiliate", title="A", dedupe_key="k-1"))
sess.flush()
sess.add(Proposal(kind="affiliate", title="B", dedupe_key="k-1"))
try:
sess.flush()
raised = False
except sa.exc.IntegrityError:
raised = True
sess.rollback()
assert raised, "duplicate dedupe_key must be rejected"
def test_audit_helper_writes(sess):
p = Proposal(kind="other", title="x")
sess.add(p)
sess.flush()
audit(sess, actor="gustavo", action="approve", proposal_id=p.id, detail={"note": "ok"})
sess.flush()
rows = list(sess.scalars(select(AuditEntry)).all())
assert len(rows) == 1
assert rows[0].actor == "gustavo"
assert rows[0].proposal_id == p.id
def test_mail_and_content_and_runs(sess):
m = MailMessage(
direction="in",
subject="Welcome to the partner program",
classification="approval",
confidence=0.9,
draft_status="drafted",
)
c = ContentItem(theme="lightning-abroad", channel="nostr", caption="gm")
r = DaemonRun(daemon="scanner", status="ok", detail={"found": 3})
sess.add_all([m, c, r])
sess.flush()
assert m.id and c.id and r.id
assert m.draft_status == "drafted"
assert c.status == "draft"
def test_audit_chain_and_verification(sess):
from cayceops.models import verify_audit_chain
p = Proposal(kind="other", title="chain")
sess.add(p)
sess.flush()
for i in range(3):
audit(sess, actor=f"actor-{i}", action="step", proposal_id=p.id, detail={"i": i})
sess.flush()
ok, broken = verify_audit_chain(sess)
assert ok and broken is None
# tamper with one entry -> chain breaks at that id
first = sess.query(AuditEntry).order_by(AuditEntry.id).first()
first.actor = "tampered"
ok, broken = verify_audit_chain(sess)
assert not ok and broken == first.id
+69
View File
@@ -0,0 +1,69 @@
"""monitor tests — page renders, no sensitive data, escapes input."""
from __future__ import annotations
from cayceops.components.monitor import monitor
from cayceops.models import ContentItem, DaemonRun, MailMessage, Proposal
def _seed(sess):
sess.add(DaemonRun(daemon="scanner", status="ok", detail={"proposed": 3}))
sess.add(Proposal(kind="affiliate", title="Title with <script>alert(1)</script>"))
sess.add(
MailMessage(
direction="in",
from_addr="secret-person@private.example.org",
subject="private subject",
classification="approval",
draft_status="drafted",
)
)
sess.add(ContentItem(theme="atm-fees", channel="nostr", caption="caption", status="draft"))
sess.flush()
def test_build_renders_all_sections(cfg, sess, tmp_path):
_seed(sess)
out = tmp_path / "status.html"
monitor._build(cfg(CAYCE_OPS_MONITOR_OUTPUT=str(out)), sess)
page = out.read_text()
assert "daemon health" in page
assert "proposal queue" in page
assert "recent audit activity" in page
assert "scanner" in page
assert "proposed: <strong>1</strong>" in page
assert "mail drafts awaiting approval: <strong>1</strong>" in page
def test_build_leaks_no_sensitive_data(cfg, sess, tmp_path):
_seed(sess)
out = tmp_path / "status.html"
monitor._build(cfg(CAYCE_OPS_MONITOR_OUTPUT=str(out)), sess)
page = out.read_text()
assert "secret-person@private.example.org" not in page # no addresses
assert "private subject" not in page # no subjects
assert "caption" not in page # no content bodies
assert "Title with" not in page # proposal titles not rendered
def test_build_escapes_injected_content(cfg, sess, tmp_path):
from datetime import UTC, datetime
from cayceops.models import AuditEntry
sess.add(
AuditEntry(
ts=datetime.now(UTC),
actor="<script>x</script>",
action="probe",
detail={},
)
)
sess.flush()
out = tmp_path / "status.html"
monitor._build(cfg(CAYCE_OPS_MONITOR_OUTPUT=str(out)), sess)
page = out.read_text()
assert "<script>x</script>" not in page
assert "&lt;script&gt;x&lt;/script&gt;" in page
+224
View File
@@ -0,0 +1,224 @@
"""publisher tests — calendar, human gate, nostr publish, disabled adapters."""
from __future__ import annotations
import os
import pytest
from cayceops.components.publisher import adapters, nostr, publisher
from cayceops.models import AuditEntry, ContentItem, ContentStatus
# invented test key (never a real nostr key — allowlisted in .gitleaks.toml)
TEST_KEY = "11" * 32
def _item(channel="nostr", theme="atm-fees", scheduled=None, status=ContentStatus.APPROVED):
return ContentItem(
theme=theme,
channel=channel,
caption="The first ATM you find charges 10-15% over spot.\n\nbitcoinhotels.org/guides/atm",
link="bitcoinhotels.org/guides/atm",
status=status,
scheduled_for=scheduled,
)
# --- nostr client -------------------------------------------------------------
def test_event_build_verify_roundtrip():
event = nostr.build_event(
bytes.fromhex(TEST_KEY), "gm", tags=[["t", "bitcoin"]], created_at=1700000000
)
assert nostr.verify_event(event)
assert len(event["id"]) == 64 and len(event["sig"]) == 128
tampered = {**event, "content": "gm (edited)"}
assert not nostr.verify_event(tampered)
def test_event_is_deterministic():
a = nostr.build_event(bytes.fromhex(TEST_KEY), "gm", created_at=1700000000)
b = nostr.build_event(bytes.fromhex(TEST_KEY), "gm", created_at=1700000000)
assert a == b
def test_key_validation():
with pytest.raises(ValueError):
nostr.normalize_key("not-a-key")
assert nostr.normalize_key("0x" + TEST_KEY) == bytes.fromhex(TEST_KEY)
# --- calendar ------------------------------------------------------------------
def test_plan_creates_idempotent_drafts(cfg, sess):
config = cfg()
first = publisher._plan(config, sess)
second = publisher._plan(config, sess)
assert first["created"] == 4
assert second["created"] == 0 # same themes+channels already queued
def test_approve_gate(cfg, sess):
item = _item(status=ContentStatus.DRAFT)
sess.add(item)
sess.flush()
assert publisher._approve(item.id, "gustavo", sess) == 0
assert item.status is ContentStatus.APPROVED
assert publisher._approve(item.id, "gustavo", sess) == 1 # not draft anymore
actor = sess.query(AuditEntry).filter_by(action="approve_content").first().actor
assert actor == "gustavo"
# --- publishing ------------------------------------------------------------------
def test_publish_due_nostr_happy_path(cfg, sess):
item = _item() # approved, scheduled None -> due now
sess.add(item)
sess.flush()
relays_seen = []
def fake_sender(relays, event, timeout=10.0):
relays_seen.append(relays)
assert nostr.verify_event(event)
return [{"relay": r, "ok": "true"} for r in relays]
config = cfg(
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY,
CAYCE_OPS_NOSTR_RELAYS="wss://relay.example.org,wss://other.example.org",
)
stats = publisher._publish_due(config, sess, nostr_sender=fake_sender)
assert stats["published"] == 1
assert item.status is ContentStatus.PUBLISHED
assert item.post_ref and len(item.post_ref) == 64
assert item.published_at is not None
assert "wss://relay.example.org" in relays_seen[0] # noqa: E501
def test_future_scheduled_item_not_published(cfg, sess):
from datetime import UTC, datetime, timedelta
item = _item(scheduled=datetime.now(UTC) + timedelta(hours=2))
sess.add(item)
sess.flush()
config = cfg(CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org")
stats = publisher._publish_due(config, sess, nostr_sender=lambda r, e, timeout=10: [])
assert stats["published"] == 0
assert item.status is ContentStatus.APPROVED
def test_nostr_without_config_fails_clean(cfg, sess):
item = _item()
sess.add(item)
sess.flush()
config = cfg() # no key, no relays
stats = publisher._publish_due(config, sess)
assert stats["failed"] == 1
assert item.status is ContentStatus.APPROVED # stays approved, error recorded
assert item.error and "nostr" in item.error
def test_non_nostr_channels_skipped_terminal(cfg, sess):
item = _item(channel="facebook", theme="lightning-abroad")
sess.add(item)
sess.flush()
stats = publisher._publish_due(cfg(), sess)
assert stats["skipped"] == 1
assert item.status is ContentStatus.SKIPPED # terminal (audit fix)
skip = sess.query(AuditEntry).filter_by(action="publish_skipped_terminal").one()
assert skip.detail["channel"] == "facebook"
def test_meta_adapters_disabled_by_default(cfg):
config = cfg()
with pytest.raises(PermissionError):
adapters.post_facebook(config, "hello")
with pytest.raises(PermissionError):
adapters.post_instagram(config, "hello", "https://img.example.org/x.jpg")
# --- images ------------------------------------------------------------------
def test_brand_image_render(tmp_path, cfg):
config = cfg(CAYCE_OPS_MEDIA_DIR=str(tmp_path))
out = publisher._render_image(_item(), config)
assert os.path.exists(out)
from PIL import Image
with Image.open(out) as img:
assert img.size == (1080, 1350)
def test_unknown_brand_rejected():
from cayceops.components.publisher import images
with pytest.raises(ValueError):
images.render_brand_image(brand="xx", kicker=None, lines=["A"], out_path="/tmp/x.jpg")
# --- audit corrections ----------------------------------------------------------
def test_publish_refuses_tampered_caption(cfg, sess):
"""Medium finding: approved caption was not bound to content."""
item = _item(status=ContentStatus.DRAFT)
sess.add(item)
sess.flush()
assert publisher._approve(item.id, "gustavo", sess) == 0
item.caption = item.caption + " TAMPERED"
config = cfg(CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org")
stats = publisher._publish_due(config, sess, nostr_sender=lambda r, e, timeout=10: [])
assert stats["published"] == 0 and stats["tampered"] == 1
assert item.status is ContentStatus.APPROVED # stays, needs re-approval
action = sess.query(AuditEntry).filter_by(action="publish_refused_hash_mismatch").one()
assert action.result == "error"
def test_disabled_channel_is_terminal(cfg, sess):
"""Medium finding: disabled channels wrote skip audits every 5 minutes."""
item = _item(channel="facebook", theme="lightning-abroad")
sess.add(item)
sess.flush()
config = cfg()
stats1 = publisher._publish_due(config, sess)
assert stats1["skipped"] == 1
assert item.status is ContentStatus.SKIPPED # terminal
stats2 = publisher._publish_due(config, sess)
assert stats2["skipped"] == 0 # never revisited
skips = sess.query(AuditEntry).filter_by(action="publish_skipped_terminal").count()
assert skips == 1
def test_publish_attempts_bounded(cfg, sess, monkeypatch):
"""Medium finding: nostr failures retried without limit."""
from cayceops.components.publisher import nostr as nostr_mod
item = _item()
sess.add(item)
sess.flush()
def always_fail(relays, event, timeout=10.0):
raise RuntimeError("relay down")
monkeypatch.setattr(nostr_mod, "publish", always_fail)
config = cfg(
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY,
CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org",
CAYCE_OPS_MAX_ATTEMPTS="1",
)
publisher._publish_due(config, sess, nostr_sender=always_fail)
assert item.attempts == 1
assert item.status is ContentStatus.SKIPPED # budget exhausted -> terminal
+112
View File
@@ -0,0 +1,112 @@
"""scanner tests — discovery, classification, dedupe. No network (injected fetch)."""
from __future__ import annotations
import json
from cayceops.components.scanner import scanner
from cayceops.models import Proposal, ProposalStatus
RSS = b"""<?xml version="1.0"?>
<rss><channel>
<item><title>Acme launches affiliate program for hotels</title>
<link>https://acme.example.org/affiliates</link>
<description>Earn commission on every booking.</description></item>
<item><title>Weekly crypto digest</title>
<link>https://news.example.org/digest</link>
<description>Everything unrelated.</description></item>
</channel></rss>"""
PAGE = b"""<html><head><title>Partners</title></head><body>
<a href="https://pay.example.org/partner">Become a payment partner</a>
<a href="https://pay.example.org/docs">API docs</a>
</body></html>"""
def _fake_fetch(responses):
def fetch(url, cfg, timeout=20.0):
return responses[url]
return fetch
SOURCES = [
{"type": "rss", "url": "https://feeds.example.org/news.xml"},
{"type": "page", "url": "https://pay.example.org/partners"},
]
def _cfg(cfg, **kw):
return cfg(
CAYCE_OPS_SCANNER_SOURCES=json.dumps(SOURCES),
CAYCE_OPS_SCANNER_KEYWORDS="affiliate,partner,bitcoin,commission",
**kw,
)
def test_scan_files_proposals_with_kind_and_score(cfg, sess):
config = _cfg(cfg)
stats = scanner.scan(config, sess, fetch=_fake_fetch({
"https://feeds.example.org/news.xml": RSS,
"https://pay.example.org/partners": PAGE,
}))
assert stats["proposed"] == 3 # affiliate item + both page links (pages keep all)
proposals = sess.query(Proposal).all()
by_title = {p.title: p for p in proposals}
affiliate = by_title["Acme launches affiliate program for hotels"]
assert affiliate.kind == "affiliate"
assert affiliate.status == ProposalStatus.PROPOSED
assert affiliate.match_score > 0
assert affiliate.dedupe_key
partner = by_title["Become a payment partner"]
assert partner.kind == "partnership"
def test_scan_is_idempotent_via_dedupe(cfg, sess):
config = _cfg(cfg)
scanner.scan(config, sess, fetch=_fake_fetch({
"https://feeds.example.org/news.xml": RSS,
"https://pay.example.org/partners": PAGE,
}))
stats2 = scanner.scan(config, sess, fetch=_fake_fetch({
"https://feeds.example.org/news.xml": RSS,
"https://pay.example.org/partners": PAGE,
}))
assert stats2["proposed"] == 0
assert stats2["skipped_existing"] == 3
def test_scan_filters_keywordless_feed_items(cfg, sess):
config = _cfg(cfg)
scanner.scan(config, sess, fetch=_fake_fetch({
"https://feeds.example.org/news.xml": RSS,
"https://pay.example.org/partners": PAGE,
}))
titles = {p.title for p in sess.query(Proposal).all()}
assert "Weekly crypto digest" not in titles # no keyword match in feed -> dropped
def test_scan_records_audit_for_each_proposal(cfg, sess):
from cayceops.models import AuditEntry
config = _cfg(cfg)
scanner.scan(config, sess, fetch=_fake_fetch({
"https://feeds.example.org/news.xml": RSS,
"https://pay.example.org/partners": PAGE,
}))
acts = [a.action for a in sess.query(AuditEntry).all()]
assert acts.count("propose") == 3
assert all(a.actor == "daemon:scanner" for a in sess.query(AuditEntry).all())
def test_scan_survives_source_error(cfg, sess):
def broken_fetch(url, cfg, timeout=20.0):
raise OSError("connection refused")
config = _cfg(cfg)
stats = scanner.scan(config, sess, fetch=broken_fetch)
assert stats["proposed"] == 0
assert len(stats["source_errors"]) == 2
Generated
+614
View File
@@ -0,0 +1,614 @@
version = 1
revision = 5
requires-python = ">=3.12"
[[package]]
name = "alembic"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6b/45/6f4555f2039f364c3ce31399529dcf48dd60726ff3715ad67f547d87dfd2/alembic-1.17.0.tar.gz", hash = "sha256:4652a0b3e19616b57d652b82bfa5e38bf5dbea0813eed971612671cb9e90c0fe", size = 1975526, upload-time = "2025-10-11T18:40:13.585Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/1f/38e29b06bfed7818ebba1f84904afdc8153ef7b6c7e0d8f3bc6643f5989c/alembic-1.17.0-py3-none-any.whl", hash = "sha256:80523bc437d41b35c5db7e525ad9d908f79de65c27d6a5a5eab6df348a352d99", size = 247449, upload-time = "2025-10-11T18:40:16.288Z" },
]
[[package]]
name = "cayce-ops"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "coincurve" },
{ name = "pillow" },
{ name = "psycopg", extra = ["binary"] },
{ name = "sqlalchemy" },
{ name = "typer" },
{ name = "websockets" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = "==1.17.0" },
{ name = "coincurve", specifier = "==21.0.0" },
{ name = "pillow", specifier = "==12.0.0" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.2.13" },
{ name = "sqlalchemy", specifier = "==2.0.44" },
{ name = "typer", specifier = "==0.20.0" },
{ name = "websockets", specifier = "==15.0.1" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = "==9.0.2" },
{ name = "ruff", specifier = "==0.15.1" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "coincurve"
version = "21.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/a2/f2a38eb05b747ed3e54e1be33be339d4a14c1f5cc6a6e2b342b5e8160d51/coincurve-21.0.0.tar.gz", hash = "sha256:8b37ce4265a82bebf0e796e21a769e56fdbf8420411ccbe3fafee4ed75b6a6e5", size = 128986, upload-time = "2025-03-08T15:31:24.266Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/61/a2d9e109f99b6f5e65e653ac998b0944c5b82c568ac142fcbb381a4803be/coincurve-21.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f60ad56113f08e8c540bb89f4f35f44d434311433195ffff22893ccfa335070c", size = 1391948, upload-time = "2025-03-08T15:30:32.899Z" },
{ url = "https://files.pythonhosted.org/packages/24/5a/2da75ee00a722ef1fa068ada3bc34c564595ead86fef573434e2f0cb0a5c/coincurve-21.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1cb1cd19fb0be22e68ecb60ad950b41f18b9b02eebeffaac9391dc31f74f08f2", size = 1384958, upload-time = "2025-03-08T15:30:34.705Z" },
{ url = "https://files.pythonhosted.org/packages/dc/50/6bf0bf7e8a9a9dd419ecc1e479dcb9fbfe657029276ad703806a25a2bef2/coincurve-21.0.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05d7e255a697b3475d7ae7640d3bdef3d5bc98ce9ce08dd387f780696606c33b", size = 1606576, upload-time = "2025-03-08T15:30:36.796Z" },
{ url = "https://files.pythonhosted.org/packages/bd/ab/9e89908fdd09ad522938085587aaa821b022f4def16c286c5580cfc85811/coincurve-21.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a366c314df7217e3357bb8c7d2cda540b0bce180705f7a0ce2d1d9e28f62ad4", size = 1613642, upload-time = "2025-03-08T15:30:38.416Z" },
{ url = "https://files.pythonhosted.org/packages/b7/75/050b6fd08978de85a7b480f0f220ab6a30967c0910119f3096a8dd40befc/coincurve-21.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b04778b75339c6e46deb9ae3bcfc2250fbe48d1324153e4310fc4996e135715", size = 1616974, upload-time = "2025-03-08T15:30:39.939Z" },
{ url = "https://files.pythonhosted.org/packages/d7/62/2740ba0cafebf45708633635fecadcbe582d7a3ed1ce8b4637921feceaf8/coincurve-21.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8efcbdcd50cc219989a2662e6c6552f455efc000a15dd6ab3ebf4f9b187f41a3", size = 1644133, upload-time = "2025-03-08T15:30:41.733Z" },
{ url = "https://files.pythonhosted.org/packages/94/14/1f27c3048c4084fa85ef65f42a4ca631f2b184336e6d9446fecec20e0a7f/coincurve-21.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6df44b4e3b7acdc1453ade52a52e3f8a5b53ecdd5a06bd200f1ec4b4e250f7d9", size = 1619918, upload-time = "2025-03-08T15:30:43.284Z" },
{ url = "https://files.pythonhosted.org/packages/ca/22/7ec3ec4c8e7764daa25767d6674cb5741ea2d9b39ff758e9918d22a4b49b/coincurve-21.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bcc0831f07cb75b91c35c13b1362e7b9dc76c376b27d01ff577bec52005e22a8", size = 1645797, upload-time = "2025-03-08T15:30:44.974Z" },
{ url = "https://files.pythonhosted.org/packages/fb/60/87982b7499943ab12605df7b14f6001fff331aca0881b260682461e2309d/coincurve-21.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:5dd7b66b83b143f3ad3861a68fc0279167a0bae44fe3931547400b7a200e90b1", size = 1329255, upload-time = "2025-03-08T15:30:46.4Z" },
{ url = "https://files.pythonhosted.org/packages/62/c0/65b60b371579570931daca8a3f67debfc1482908b8ed03432297274a27da/coincurve-21.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:78dbe439e8cb22389956a4f2f2312813b4bd0531a0b691d4f8e868c7b366555d", size = 1325973, upload-time = "2025-03-08T15:30:48.056Z" },
{ url = "https://files.pythonhosted.org/packages/b3/40/cce55adaec37a588eb24b67da8eb68926546458e12ed2c4c2a21deb93d4c/coincurve-21.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9df5ceb5de603b9caf270629996710cf5ed1d43346887bc3895a11258644b65b", size = 1391762, upload-time = "2025-03-08T15:30:49.586Z" },
{ url = "https://files.pythonhosted.org/packages/ca/7a/628a30281d246ce98aea56592e0c8e79b03a93ee8b85d688db3388130c2d/coincurve-21.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:154467858d23c48f9e5ab380433bc2625027b50617400e2984cc16f5799ab601", size = 1384921, upload-time = "2025-03-08T15:30:51.103Z" },
{ url = "https://files.pythonhosted.org/packages/61/cc/719c5da31e6ba07e438abcf962f7a365eb69a06a0621ca4f2a484f344e09/coincurve-21.0.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f57f07c44d14d939bed289cdeaba4acb986bba9f729a796b6a341eab1661eedc", size = 1606559, upload-time = "2025-03-08T15:30:53.218Z" },
{ url = "https://files.pythonhosted.org/packages/b2/ee/dd14237013d732e7fc3248c0c33a1d36b88b5378dfa3e624a50a23fb6f19/coincurve-21.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fb03e3a388a93d31ed56a442bdec7983ea404490e21e12af76fb1dbf097082a", size = 1613684, upload-time = "2025-03-08T15:30:55.087Z" },
{ url = "https://files.pythonhosted.org/packages/f0/05/eaa7f36a03376ced1c19e0cb563341cc83fe48f5734b2effe8f16d0ee0ab/coincurve-21.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d09ba4fd9d26b00b06645fcd768c5ad44832a1fa847ebe8fb44970d3204c3cb7", size = 1617001, upload-time = "2025-03-08T15:30:57.036Z" },
{ url = "https://files.pythonhosted.org/packages/39/32/fc75f1dd914ac95eb2704425c7ca1a9f509f982e15d05e0ca895b9e6ea9c/coincurve-21.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1a1e7ee73bc1b3bcf14c7b0d1f44e6485785d3b53ef7b16173c36d3cefa57f93", size = 1643924, upload-time = "2025-03-08T15:30:58.737Z" },
{ url = "https://files.pythonhosted.org/packages/1a/4b/8c6e65b5755e26fc02077803879747615c1c327047328d1784bccb4ff4c3/coincurve-21.0.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ad05952b6edc593a874df61f1bc79db99d716ec48ba4302d699e14a419fe6f51", size = 1619964, upload-time = "2025-03-08T15:31:00.275Z" },
{ url = "https://files.pythonhosted.org/packages/64/bc/d0a743305ff9fa26e72b4c77b534d5958ec8030b3772555a7172a0c134e5/coincurve-21.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d2bf350ced38b73db9efa1ff8fd16a67a1cb35abb2dda50d89661b531f03fd3", size = 1645526, upload-time = "2025-03-08T15:31:01.952Z" },
{ url = "https://files.pythonhosted.org/packages/9d/44/ab082e2dc8c9a45774f1bb9961f58b43c0882b866f5c469ead932d45a35d/coincurve-21.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:54d9500c56d5499375e579c3917472ffcf804c3584dd79052a79974280985c74", size = 1329285, upload-time = "2025-03-08T15:31:03.591Z" },
{ url = "https://files.pythonhosted.org/packages/f3/94/407f6fc811310f15b1fc7255f436f6a9040854213beeb10093f56b5b7fd3/coincurve-21.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:773917f075ec4b94a7a742637d303a3a082616a115c36568eb6c873a8d950d18", size = 1326027, upload-time = "2025-03-08T15:31:05.318Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "greenlet"
version = "3.5.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" },
{ url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" },
{ url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" },
{ url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" },
{ url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" },
{ url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" },
{ url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" },
{ url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" },
{ url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" },
{ url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" },
{ url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" },
{ url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" },
{ url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" },
{ url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" },
{ url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" },
{ url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" },
{ url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" },
{ url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" },
{ url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" },
{ url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" },
{ url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" },
{ url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" },
{ url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" },
{ url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" },
{ url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" },
{ url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" },
{ url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" },
{ url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" },
{ url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" },
{ url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" },
{ url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" },
{ url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" },
{ url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" },
{ url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" },
{ url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" },
{ url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" },
{ url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" },
{ url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" },
{ url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" },
{ url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" },
{ url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" },
{ url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "mako"
version = "1.4.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/09/e07c4b5579a79f4b16f8d4f29f6c54514ac787c4ad506b8c4f28a0e6b0bf/mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a", size = 412799, upload-time = "2026-09-22T20:54:31.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/a0/053d6af3e8f871e0073b4a36732d9e65be77a72e5434c31b94f6af78a6bb/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f", size = 80164, upload-time = "2026-09-22T20:54:33.128Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" },
{ url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" },
{ url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" },
{ url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" },
{ url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" },
{ url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" },
{ url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" },
{ url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" },
{ url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" },
{ url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" },
{ url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" },
{ url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" },
{ url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" },
{ url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" },
{ url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" },
{ url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" },
{ url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" },
{ url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" },
{ url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" },
{ url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" },
{ url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" },
{ url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" },
{ url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" },
{ url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" },
{ url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" },
{ url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" },
{ url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" },
{ url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" },
{ url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" },
{ url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" },
{ url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" },
{ url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" },
{ url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" },
{ url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" },
{ url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" },
{ url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" },
{ url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" },
{ url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" },
{ url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" },
{ url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" },
{ url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" },
{ url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" },
{ url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" },
{ url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" },
{ url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" },
{ url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" },
{ url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" },
{ url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" },
{ url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" },
{ url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" },
{ url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" },
{ url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" },
{ url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" },
{ url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" },
{ url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" },
{ url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" },
{ url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" },
{ url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" },
{ url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" },
{ url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" },
{ url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" },
{ url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" },
{ url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" },
{ url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" },
{ url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" },
{ url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" },
{ url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" },
{ url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" },
{ url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" },
{ url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" },
{ url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" },
{ url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" },
{ url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" },
{ url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" },
{ url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" },
{ url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" },
{ url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" },
{ url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" },
{ url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" },
{ url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" },
{ url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" },
{ url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" },
{ url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" },
{ url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" },
{ url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" },
{ url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" },
{ url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" },
{ url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" },
{ url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" },
{ url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" },
{ url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" },
{ url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" },
{ url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" },
{ url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" },
{ url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" },
{ url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pillow"
version = "12.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" },
{ url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" },
{ url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" },
{ url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" },
{ url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" },
{ url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" },
{ url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" },
{ url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" },
{ url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" },
{ url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" },
{ url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" },
{ url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" },
{ url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" },
{ url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" },
{ url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" },
{ url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" },
{ url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" },
{ url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" },
{ url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" },
{ url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" },
{ url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" },
{ url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" },
{ url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" },
{ url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" },
{ url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" },
{ url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" },
{ url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" },
{ url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
{ url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
{ url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
{ url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
{ url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
{ url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
{ url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
{ url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
{ url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
{ url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
{ url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
{ url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
{ url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
{ url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
{ url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
{ url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
{ url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
{ url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
{ url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
{ url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
{ url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
]
[[package]]
name = "pluggy"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
]
[[package]]
name = "psycopg"
version = "3.2.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/05/d4a05988f15fcf90e0088c735b1f2fc04a30b7fc65461d6ec278f5f2f17a/psycopg-3.2.13.tar.gz", hash = "sha256:309adaeda61d44556046ec9a83a93f42bbe5310120b1995f3af49ab6d9f13c1d", size = 160626, upload-time = "2025-11-21T22:34:32.328Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/14/f2724bd1986158a348316e86fdd0837a838b14a711df3f00e47fba597447/psycopg-3.2.13-py3-none-any.whl", hash = "sha256:a481374514f2da627157f767a9336705ebefe93ea7a0522a6cbacba165da179a", size = 206797, upload-time = "2025-11-21T22:29:39.733Z" },
]
[package.optional-dependencies]
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
[[package]]
name = "psycopg-binary"
version = "3.2.13"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/9e/f90243b3d0d007a89989b013b0eb3e78ac929fed4eb40a2b317452abafe1/psycopg_binary-3.2.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:223fc610a80bbc4355ad3c9952d468a18bb5cd7065846a8c275f100d80cd4004", size = 3996285, upload-time = "2025-11-21T22:31:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/12/42/7d55f515ee3e2ced5ff9bc493fb2308f5187686b6d9583cd6a9c880d2053/psycopg_binary-3.2.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67f06a68d68b4621b6a411f9e583df876977afa06b1ba270b1b347d40aa93fc", size = 4070567, upload-time = "2025-11-21T22:31:12.31Z" },
{ url = "https://files.pythonhosted.org/packages/a8/a8/ead4de04d8cf5f35119a75a8dd92fa4a2ec8a309b1aa58855f64616c03d7/psycopg_binary-3.2.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:082579f2ae41bdabe20c82810810f3e290ac2206cccf0cb41cf36b3218f53b3c", size = 4616833, upload-time = "2025-11-21T22:31:16.614Z" },
{ url = "https://files.pythonhosted.org/packages/26/2e/4af6ab69ade7d67d31296f88c79c322a3522564e30b3f1458f19e74d67c3/psycopg_binary-3.2.13-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ff7df7bd8ec2c805f3a4896b8ade971139af0f9f8cf45d05014ac71fe54887be", size = 4711710, upload-time = "2025-11-21T22:31:22.007Z" },
{ url = "https://files.pythonhosted.org/packages/9a/31/bdbd6b2264bb7ae5fe8b775c5524da73329d8888c6137fd8b050ff9cabbc/psycopg_binary-3.2.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f1189dc78553ef4b2e55d9e116fc74870191bc6a9a5f4442412a703c4cc6c3b", size = 4401656, upload-time = "2025-11-21T22:31:26.842Z" },
{ url = "https://files.pythonhosted.org/packages/33/c5/8fd8f96450e4ef242022c9a588305e3dc7309c34bc392a9b4c2da60854b1/psycopg_binary-3.2.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0ef8ed4a4e0f7bf5e941782478a43c14b2b585b031e2266dd3afb87be2775d95", size = 3851747, upload-time = "2025-11-21T22:31:30.5Z" },
{ url = "https://files.pythonhosted.org/packages/4a/47/406d102ae49d253f124644530f1e5b3fd2f92aea59d4f9b8dd1c71cf8e0f/psycopg_binary-3.2.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:de06fc9707a49f7c081b5c950974dd6de3dc33d681f7524f0b396471f5a4a480", size = 3524796, upload-time = "2025-11-21T22:31:34.377Z" },
{ url = "https://files.pythonhosted.org/packages/45/6f/a89be8aee27a5522e97dbcb225fe429c489acdf0bb25fc0fadb329dfb39f/psycopg_binary-3.2.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:917ad1cd6e6ef8a9df2f28d7b29c7148f089be46ac56fe838f986c0227652d14", size = 3576536, upload-time = "2025-11-21T22:31:38.06Z" },
{ url = "https://files.pythonhosted.org/packages/ef/f8/c924c7dc792c81bf6181d7d4eeb613c8b2151b3a208f95cedec3c1a25ba3/psycopg_binary-3.2.13-cp312-cp312-win_amd64.whl", hash = "sha256:b53b0d9499805b307017070492189e349256e0946f62c815e442baa01f2ea6c5", size = 2902172, upload-time = "2025-11-21T22:31:41.256Z" },
{ url = "https://files.pythonhosted.org/packages/28/ec/ef37bb44dc02fcc6c0a3eeb93f4baaac13bcb228633fe38ad3fb5a3f6449/psycopg_binary-3.2.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbae6ab1966e2b61d97e47220556c330c4608bb4cfb3a124aa0595c39995c068", size = 3995628, upload-time = "2025-11-21T22:31:45.921Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ad/4748f5f1a40248af16dba087dbec50bd335ee025cc1fb9bf64773378ceff/psycopg_binary-3.2.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fae933e4564386199fc54845d85413eedb49760e0bcd2b621fde2dd1825b99b3", size = 4069024, upload-time = "2025-11-21T22:31:50.202Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c2/f02ec6bbc30c7fcd3b39823d2d624b42fae480edeb6e50eb3276281d5635/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:13e2f8894d410678529ff9f1211f96c5a93ff142f992b302682b42d924428b61", size = 4615127, upload-time = "2025-11-21T22:31:56.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/0d/a54fc2cdd672c84175d6869cc823d6ec2a8909318d491f3c24e6077983f2/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f26f7009375cf1e92180e5c517c52da1054f7e690dde90e0ed00fa8b5736bcd4", size = 4710267, upload-time = "2025-11-21T22:32:04.585Z" },
{ url = "https://files.pythonhosted.org/packages/9d/b7/067de1acaf3d312253351f3af4121f972584bd36cada6378d4b0cdcebd38/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea2fdbcc9142933a47c66970e0df8b363e3bd1ea4c5ce376f2f3d94a9aeec847", size = 4400795, upload-time = "2025-11-21T22:32:08.883Z" },
{ url = "https://files.pythonhosted.org/packages/64/b5/030e6b1ebfc4d3a8fca03adc5fc827982643bad0b01a1268538d17c08ed3/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac92d6bc1d4a41c7459953a9aa727b9966e937e94c9e072527317fd2a67d488b", size = 3851239, upload-time = "2025-11-21T22:32:12.333Z" },
{ url = "https://files.pythonhosted.org/packages/79/6f/0541845364a7de9eae6807060da6a04b22a8eb2e803606d285d9250fbe93/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8b843c00478739e95c46d6d3472b13123b634685f107831a9bfc41503a06ecbd", size = 3525084, upload-time = "2025-11-21T22:32:15.946Z" },
{ url = "https://files.pythonhosted.org/packages/83/ae/6507890dc30a4bbd9d938d4ff3a4079d009a5ad8170af51c7f762438fdbf/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2f63868cc96bc18486cebec24445affbdd7f7debf28fac466ea935a8b5a4753b", size = 3576787, upload-time = "2025-11-21T22:32:19.922Z" },
{ url = "https://files.pythonhosted.org/packages/9d/64/3d1c2f1fd09b60cdfbe68b9a810b357ba505eff6e4bdb1a2d9f6729da64c/psycopg_binary-3.2.13-cp313-cp313-win_amd64.whl", hash = "sha256:594dfbca3326e997ae738d3d339004e8416b1f7390f52ce8dc2d692393e8fa96", size = 2905584, upload-time = "2025-11-21T22:32:23.399Z" },
{ url = "https://files.pythonhosted.org/packages/d3/b4/7656b3d67bedff2b900c8c4671cb6eb5fb99c2fc36da33579cac89779c25/psycopg_binary-3.2.13-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:502a778c3e07c6b3aabfa56ee230e8c264d2debfab42d11535513a01bdfff0d6", size = 3997201, upload-time = "2025-11-21T22:32:28.185Z" },
{ url = "https://files.pythonhosted.org/packages/e0/2e/3b4afbd94d48df19c3931cedba464b109f89d81ac43178e6a3d654b4e8d5/psycopg_binary-3.2.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7561a71d764d6f74d66e8b7d844b0f27fa33de508f65c17b1d56a94c73644776", size = 4071631, upload-time = "2025-11-21T22:32:32.594Z" },
{ url = "https://files.pythonhosted.org/packages/5e/8b/107d06d55992e2f13157eb705ba5a47d06c4cf1bed077dff0c567b10c187/psycopg_binary-3.2.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9caf14745a1930b4e03fe4072cd7154eaf6e1241d20c42130ed784408a26b24b", size = 4620918, upload-time = "2025-11-21T22:32:37.357Z" },
{ url = "https://files.pythonhosted.org/packages/e1/47/a925620f261b115f31e813a5bfe640f316413b1864094a60162f4a6e4d67/psycopg_binary-3.2.13-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a6cafabdc0bfa37e11c6f365020fd5916b62d6296df581f4dceaa43a2ce680c", size = 4714494, upload-time = "2025-11-21T22:32:42.138Z" },
{ url = "https://files.pythonhosted.org/packages/46/33/bed384665356bb9ba17dd8e104884d87cc2343d16dffdfd9aaa9a159bd4d/psycopg_binary-3.2.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96cb5a27e68acac6d74b64fca38592a692de9c4b7827339190698d58027aa45", size = 4403046, upload-time = "2025-11-21T22:32:47.241Z" },
{ url = "https://files.pythonhosted.org/packages/41/88/749d8e8102fb5df502e2ecb053b79e78e3358af01af652b5dbeb96ab7905/psycopg_binary-3.2.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:596176ae3dfbf56fc61108870bfe17c7205d33ac28d524909feb5335201daa0a", size = 3859046, upload-time = "2025-11-21T22:32:51.481Z" },
{ url = "https://files.pythonhosted.org/packages/38/7c/f492e63b517d6dcd564e8c43bc15e11a4c712a848adf8938ce33bfd4c867/psycopg_binary-3.2.13-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:cc3a0408435dfbb77eeca5e8050df4b19a6e9b7e5e5583edf524c4a83d6293b2", size = 3531351, upload-time = "2025-11-21T22:32:55.571Z" },
{ url = "https://files.pythonhosted.org/packages/07/5a/d8743eb23944e5cf2a0bbfa92935c140b5beaacdb872be641065ed70ab2c/psycopg_binary-3.2.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65df0d459ffba14082d8ca4bb2f6ffbb2f8d02968f7d34a747e1031934b76b23", size = 3581034, upload-time = "2025-11-21T22:33:01.648Z" },
{ url = "https://files.pythonhosted.org/packages/46/b2/411d4180252144f7eff024894d2d2ebb98c012c944a282fc20250870e461/psycopg_binary-3.2.13-cp314-cp314-win_amd64.whl", hash = "sha256:5c77f156c7316529ed371b5f95a51139e531328ee39c37493a2afcbc1f79d5de", size = 3000162, upload-time = "2025-11-21T22:33:07.378Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "ruff"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.44"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/c4/59c7c9b068e6813c898b771204aad36683c96318ed12d4233e1b18762164/sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250", size = 2139675, upload-time = "2025-10-10T16:03:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ae/eeb0920537a6f9c5a3708e4a5fc55af25900216bdb4847ec29cfddf3bf3a/sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29", size = 2127726, upload-time = "2025-10-10T16:03:35.934Z" },
{ url = "https://files.pythonhosted.org/packages/d8/d5/2ebbabe0379418eda8041c06b0b551f213576bfe4c2f09d77c06c07c8cc5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44", size = 3327603, upload-time = "2025-10-10T15:35:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/45/e5/5aa65852dadc24b7d8ae75b7efb8d19303ed6ac93482e60c44a585930ea5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1", size = 3337842, upload-time = "2025-10-10T15:43:45.431Z" },
{ url = "https://files.pythonhosted.org/packages/41/92/648f1afd3f20b71e880ca797a960f638d39d243e233a7082c93093c22378/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7", size = 3264558, upload-time = "2025-10-10T15:35:29.93Z" },
{ url = "https://files.pythonhosted.org/packages/40/cf/e27d7ee61a10f74b17740918e23cbc5bc62011b48282170dc4c66da8ec0f/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d", size = 3301570, upload-time = "2025-10-10T15:43:48.407Z" },
{ url = "https://files.pythonhosted.org/packages/3b/3d/3116a9a7b63e780fb402799b6da227435be878b6846b192f076d2f838654/sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4", size = 2103447, upload-time = "2025-10-10T15:03:21.678Z" },
{ url = "https://files.pythonhosted.org/packages/25/83/24690e9dfc241e6ab062df82cc0df7f4231c79ba98b273fa496fb3dd78ed/sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e", size = 2130912, upload-time = "2025-10-10T15:03:24.656Z" },
{ url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479, upload-time = "2025-10-10T16:03:37.671Z" },
{ url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212, upload-time = "2025-10-10T16:03:41.755Z" },
{ url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353, upload-time = "2025-10-10T15:35:31.221Z" },
{ url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222, upload-time = "2025-10-10T15:43:50.124Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614, upload-time = "2025-10-10T15:35:32.578Z" },
{ url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248, upload-time = "2025-10-10T15:43:51.862Z" },
{ url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275, upload-time = "2025-10-10T15:03:26.096Z" },
{ url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901, upload-time = "2025-10-10T15:03:27.548Z" },
{ url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" },
]
[[package]]
name = "typer"
version = "0.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "tzdata"
version = "2026.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" },
]
[[package]]
name = "websockets"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]