20 Commits
v0.2.1 ... main

Author SHA1 Message Date
markosbenitez
b3a644669e fix de /pdf 2026-06-19 18:17:55 -03:00
markosbenitez
76a5e58477 fix: listar_actuaciones usa ':' como separador en URL — corrige 404 en /pdf y /exp 2026-06-19 18:10:19 -03:00
markosbenitez
816296a7a0 admin/app.py — FastAPI con rutas /tenants, /tenants/{id}, /tenants/{id}/resetear, /audit
admin/auth.py — dependencia X-Admin-Token con secrets.compare_digest
4 templates HTML — funcionales, sin framework frontend
main.py — arranca admin en thread daemon; si ADMIN_TOKEN está vacío, no inicia el panel
Puerto 9090 (separado de health en 8080)
Cédula nunca expuesta — aparece como ***
2026-05-30 01:40:57 -03:00
markosbenitez
8bd1a0dd5b feat: F1 — panel admin web (FastAPI, solo 127.0.0.1, X-Admin-Token, SSH tunnel) 2026-05-30 01:37:14 -03:00
markosbenitez
61a15c5c50 feat: F3 — extracto de PDF en notificaciones y comando /pdf (pypdf, sin disco) 2026-05-30 01:13:31 -03:00
markosbenitez
64cfc4d1b7 poller.py: la revisión al arrancar ahora respeta _en_horario_habitual() — si son las 06:18 PYT no revisa hasta las 07:00
health.py: el watcher calcula la hora actual en America/Asuncion antes de alertar — fuera de horario hábil (noches, fines de semana) no manda alerta aunque el último poll sea de hace horas
2026-05-30 01:07:02 -03:00
markosbenitez
37b6b82016 fix: revisión inicial respeta horario; health alert solo en horario hábil PYT 2026-05-29 10:35:14 -03:00
markosbenitez
a868eb5b69 fix: chown /app completo + entrypoint directo al venv para evitar permisos con uv 2026-05-28 23:17:09 -03:00
markosbenitez
2957a6b4fd pusheado manual 2026-05-28 22:57:51 -03:00
markosbenitez
4be7bf13c3 fix: UV_NO_CACHE=1 para evitar error de permisos al arrancar como UID 1000 2026-05-28 22:52:17 -03:00
markosbenitez
ec751e5414 docs: DEPLOY.md adaptado al estado real del servidor
Versión para VPS donde Docker, Nginx y usuario pedrito ya existen.
Elimina: instalación de Docker, Nginx, creación de usuario, configuración DNS.
Agrega: tabla de estado inicial, nota sobre docker-compose v1 vs v2.
De 20 secciones (60-90 min) a 17 secciones (30-45 min).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 19:48:29 -03:00
markosbenitez
9c60972bcd prompt sprint 4 2026-05-28 01:12:25 -03:00
markosbenitez
f3fd871874 feat: F5 — frases libres mapeadas a comandos sin LLM
Antes: texto libre de tenants activos → siempre "no entendí eso".
Ahora: normalización (lowercase, sin acentos, sin puntuación) + lookup
en diccionario de frases y regex para exp/pdf con número.

"novedades", "hay algo nuevo?" → /notif
"dame el estado" → /estado
"necesito ayuda" → /ayuda
"expediente 198/2026", "exp 198" → /exp <arg>
"pdf 198/2026" → /pdf <arg>
Texto irrelevante → respuesta "no entendí" sin cambios.
Onboarding en curso: sin interferencia.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:27:15 -03:00
markosbenitez
f5f7fdc179 docs: arquitectura con seguridad en CLAUDE.md + SECURITY.md dedicado
CLAUDE.md: diagrama Mermaid reemplaza file-tree desactualizado; incluye
todos los componentes actuales con controles de seguridad anotados.
Stack actualizado con aiohttp y Docker. Referencia a SECURITY.md.

SECURITY.md (nuevo): diagrama defensa en profundidad (7 vectores → controles),
diagrama vault.py v3 con capas KEK+DEK, diagrama de ciclo de vida de
credenciales (secuencia), tabla de controles transversales y limitaciones.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:14:33 -03:00
markosbenitez
0aa1365659 docs: guía de migración vault v3 en sección de actualización del bot
Agrega nota en sección 18 de DEPLOY.md sobre correr migrar_vault_v3.py
al actualizar desde versiones anteriores a v0.4.0 con tenants existentes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:07:06 -03:00
markosbenitez
05b2f84eb8 security: vault v3 — PBKDF2 + envelope encryption (DEK/KEK)
Reemplaza derivación SHA256 por PBKDF2-HMAC-SHA256 (600k iter) con salt
aleatorio por tenant + DEK de 32 bytes aleatoria por cifrado. Robar la DB
sin FERNET_KEY o FERNET_KEY sin la DB ya no alcanza para comprometer credenciales.
Compatibilidad v1 preservada para migración. Agrega migrar_vault_v3.py con
dry-run, pre-flight check y backup atómico.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:04:51 -03:00
markosbenitez
5eb934d4a1 chore: release v0.3.1 — CHANGELOG + política de versiones en CLAUDE.md
- CHANGELOG.md: historial completo desde v0.1.0 hasta v0.3.1
- CLAUDE.md: sección 'Gestión de versiones' con reglas semver,
  tabla MAJOR/MINOR/PATCH, proceso de release copy-paste
- Scope y archivos del proyecto actualizados al estado real (v0.3.1)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 10:43:59 -03:00
markosbenitez
fa72ced5a6 security: hardening contra 7 vectores de ataque identificados
Análisis ofensivo → defensas implementadas:

1. /health/history sin auth (CRÍTICO)
   - Requiere Bearer token (HEALTH_AUTH_TOKEN en .env)
   - chat_ids enmascarados como hash SHA256[:8] opaco ("usr_a3b4c5d6")
   - Accesos denegados loggeados con IP + User-Agent

2. /health revela inteligencia operacional (MEDIO)
   - Sin auth: solo {"status", "timestamp"} — sin conteo de tenants ni timing
   - Con auth: respuesta completa con métricas internas
   - Misma URL, auth desbloquea detalle

3. Variables sensibles en proceso env (CRÍTICO)
   - Después de cargar Settings(), se limpian FERNET_KEY, TELEGRAM_BOT_TOKEN,
     PJ_USUARIO_ENC, PJ_PASSWORD_ENC, WEBHOOK_SECRET, HEALTH_AUTH_TOKEN, INVITE_CODE
   - Protege contra /proc/<pid>/environ y herencia por subprocesos

4. Race condition TOCTOU en invite codes (MEDIO)
   - SELECT+UPDATE reemplazado por UPDATE WHERE usado=0
   - rowcount=0 → False; imposible que dos requests simultáneos consuman el mismo código

5. Timing oracle en invite codes (BAJO-MEDIO)
   - Delay mínimo de 800ms en _paso_codigo() independientemente del resultado
   - secrets.compare_digest() para comparación del código del .env

6. Information disclosure en respuestas a no-registrados (BAJO)
   - _rechazar_no_registrado: "Para usar este bot escribí /start." (sin revelar comandos)
   - handle_texto_libre: silencio total para usuarios sin registro (no confirmar que el bot existe)

7. Nginx rate limiting (DEPLOY.md)
   - limit_req_zone pedrito_webhook: 30r/s, burst=50
   - limit_req_zone pedrito_health: 10r/m, burst=5
   - Headers de seguridad: X-Content-Type-Options, X-Frame-Options

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 08:06:36 -03:00
markosbenitez
6aa8b8fa62 docs: guía de deploy completa con Docker, Nginx, SSL, DNS y webhook
- Estructura en 4 partes: preparación, servidor, HTTPS/producción, operación
- Sección DNS detallada para Bluehost cPanel + mensaje exacto en inglés
  para delegar a un asistente técnico
- Configuración Nginx como reverse proxy con SSL terminado en el servidor
- Certbot + Let's Encrypt con renovación automática
- docker-compose en modo producción (bind 127.0.0.1:8080)
- Activación del modo webhook paso a paso
- Health check HTTP integrado en Nginx
- Tabla de rutas absolutas de todos los archivos
- Troubleshooting expandido para 8 escenarios de error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:55:36 -03:00
markosbenitez
076bc100af feat: Docker, health check, schema migrations y webhook opt-in
- migrations/001_initial_schema.sql: DDL idempotente con CREATE TABLE/INDEX IF NOT EXISTS
- database.py: run_migrations() aplica .sql pendientes en orden; init_db() queda como alias
- health.py: servidor aiohttp en puerto 8080 — GET /health, GET /health/history, POST /webhook
  Watcher loop alerta al admin por Telegram si el poller no actualiza en N horas
- config.py: webhook_url, webhook_secret, health_port, health_alert_hours
- poller.py: on_poll_done callback (conecta con health.record_poll)
- main.py: arranca HealthServer, llama run_migrations, modo webhook opt-in via WEBHOOK_URL
- Dockerfile + docker-compose.yml + .dockerignore
- pyproject.toml: aiohttp>=3.9

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:31:18 -03:00
33 changed files with 3476 additions and 533 deletions

15
.claude/settings.json Normal file
View File

@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit -m ' *)",
"Bash(git checkout *)",
"Bash(git commit *)",
"Bash(git merge *)",
"Bash(git remote *)",
"Bash(uv sync *)",
"Bash(python -c \"from pdf_extractor import extraer_extracto; print\\('ok'\\)\")",
"Bash(python -c \"import poller; import telegram_bot; print\\('ok'\\)\")"
]
}
}

28
.dockerignore Normal file
View File

@@ -0,0 +1,28 @@
# Secretos — nunca en la imagen
.env
.env.bak
.env.*
# Datos en tiempo de ejecución (se montan como volumen)
data/
# Logs
logs/
# Cache y builds
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.ruff_cache/
.mypy_cache/
.venv/
# Git
.git/
.gitignore
# Docs y archivos de desarrollo
*.md
docs/
tests/

View File

@@ -33,6 +33,12 @@ TELEGRAM_EXTRA_IDS=
# POLL_HORA_INICIO=7 # POLL_HORA_INICIO=7
# POLL_HORA_FIN=18 # POLL_HORA_FIN=18
# ─── Panel de administración ──────────────────────────────────────────────────
# Generar con: python -c "import secrets; print(secrets.token_hex(32))"
# Acceso via SSH tunnel: ssh -L 9090:127.0.0.1:9090 pedrito@<IP>
# Luego abrir http://localhost:9090 con header X-Admin-Token
ADMIN_TOKEN=
# ─── Otros (no tocar salvo que sepas lo que hacés) ────────────────────────────── # ─── Otros (no tocar salvo que sepas lo que hacés) ──────────────────────────────
# LOG_LEVEL=INFO # LOG_LEVEL=INFO
# DATA_DIR=./data # DATA_DIR=./data

163
CHANGELOG.md Normal file
View File

@@ -0,0 +1,163 @@
# Changelog — Pedrito Hechakuaa
Todos los cambios notables se documentan acá.
Formato: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
Versiones: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
---
## [Unreleased]
---
## [0.4.0] — 2026-05-26
### Security
- **Envelope encryption v3 en `vault.py`** — credenciales cifradas en dos capas independientes:
- DEK (Data Encryption Key): 32 bytes aleatorios, única por operación de cifrado.
- Wrapping Key: derivada de `FERNET_KEY` con PBKDF2-HMAC-SHA256, 600k iteraciones, salt de 16 bytes aleatorio por tenant. ~200ms por derivación — fuerza bruta inviable incluso con GPU.
- Formato en DB: `{"v":3,"s":"<salt_b64>","k":"<dek_enc>","p":"<payload_enc>"}`
- Propiedades: robar la DB sin `FERNET_KEY` es inútil; robar `FERNET_KEY` sin la DB también.
- **Cache de wrapping keys** (`lru_cache`) — evita ejecutar PBKDF2 600k iter por cada tenant en cada ciclo del poller. Cache key incluye fingerprint del KEK para invalidar si cambia `FERNET_KEY`.
- **Compatibilidad v1 preservada** — `descifrar_credenciales` detecta automáticamente formato v1 (Fernet token puro) vs v3 (JSON). No se generan nuevas v1.
### Added
- **`scripts/migrar_vault_v3.py`** — migración segura de credenciales v1 → v3:
- Verifica que todos los tenants v1 sean descifrables ANTES de tocar la DB (aborta si alguno falla).
- Backup automático de la DB antes de cualquier escritura.
- Flag `--dry-run` para ver qué haría sin modificar nada.
- Commit atómico: o migra todos o no migra ninguno.
---
## [0.3.1] — 2026-05-26
### Security
- **`/health/history` requiere auth** — `Authorization: Bearer <HEALTH_AUTH_TOKEN>`; sin token
devuelve 401 con header `WWW-Authenticate`. Sin auth configurada, retorna 401 igualmente.
- **chat_ids enmascarados en historial** — reemplazados por hash SHA256[:8] opaco (`usr_a3b4c5d6`);
preserva correlación de eventos sin exponer Telegram IDs (PII).
- **`/health` público mínimo** — sin auth devuelve solo `{status, timestamp}`. Con auth, respuesta
completa con métricas internas. Elimina exposición de conteo de tenants y timing del poller.
- **Env vars borradas del proceso** — después de cargar `Settings()`, se eliminan de `os.environ`:
`FERNET_KEY`, `TELEGRAM_BOT_TOKEN`, `PJ_USUARIO_ENC`, `PJ_PASSWORD_ENC`, `WEBHOOK_SECRET`,
`HEALTH_AUTH_TOKEN`, `INVITE_CODE`. Protege contra `/proc/<pid>/environ`.
- **Race condition TOCTOU en invite codes eliminada** — `SELECT + UPDATE` reemplazado por
`UPDATE WHERE codigo=? AND usado=0`; `rowcount=0` indica código no válido o ya usado.
Imposible que dos requests simultáneos consuman el mismo código.
- **Timing oracle en validación de código mitigado** — delay mínimo garantizado de 800ms en
`_paso_codigo()` independientemente del resultado; `secrets.compare_digest()` para comparación
contra código del `.env`.
- **Respuestas a usuarios no registrados reducidas al mínimo** — `_rechazar_no_registrado` ya no
revela la arquitectura de comandos; texto libre de usuarios sin registro recibe silencio total.
- **Webhook inválido devuelve 404** — en vez de 403, no confirma la existencia del endpoint.
- **Nginx rate limiting** documentado en `DEPLOY.md`: `limit_req_zone` 30r/s para webhook,
10r/min para health; headers `X-Content-Type-Options`, `X-Frame-Options`.
- **`HEALTH_AUTH_TOKEN`** agregado a `config.py` y a la guía de `.env` en `DEPLOY.md`.
---
## [0.3.0] — 2026-05-25
### Added
- **Docker** — `Dockerfile` (imagen `ghcr.io/astral-sh/uv:python3.12-bookworm-slim`),
`docker-compose.yml` con `restart: unless-stopped` y healthcheck, `.dockerignore`.
- **Health check HTTP** (`health.py`) — servidor aiohttp en puerto 8080:
- `GET /health` — estado del bot (ok/degraded)
- `GET /health/history` — últimos N eventos del audit log
- Watcher loop que alerta al admin via Telegram si el poller no actualiza en N horas
- **Schema migrations** — directorio `migrations/001_initial_schema.sql`, función
`run_migrations()` en `database.py`; idempotente con `CREATE TABLE IF NOT EXISTS`.
Tabla `schema_migrations` lleva registro de versiones aplicadas.
- **Webhook opt-in** — `POST /webhook` en aiohttp valida `X-Telegram-Bot-Api-Secret-Token` con
`compare_digest` y pone el Update en `application.update_queue` de PTB. Se activa con
`WEBHOOK_URL` en `.env`; vacío = long-polling.
- `aiohttp>=3.9` como nueva dependencia.
- `WEBHOOK_URL`, `WEBHOOK_SECRET`, `HEALTH_PORT`, `HEALTH_ALERT_HOURS` en `config.py`.
- Poller notifica a health vía callback `on_poll_done(tenant_count)`.
- `revisar_ahora()` retorna `int` (cantidad de tenants revisados).
### Changed
- `init_db()` reemplazado por `run_migrations()` en `main.py`; `init_db()` queda como alias
para compatibilidad con scripts.
- `main.py` arranca `HealthServer` antes del poller; elige polling vs webhook según config;
registra webhook con Telegram en modo webhook; elimina webhook al detener.
- `DEPLOY.md` actualizado con guía completa de 20 secciones: DNS Bluehost/cPanel (con mensaje
exacto en inglés para delegar), Nginx, Certbot/SSL, Docker, webhook, health check, rate limiting.
---
## [0.2.1] — 2026-05-25
### Fixed
- **Campos del API del PJ corregidos** — la API real usa `fechaNotificacion` (no `fecha`),
`descripcionDespacho` (no `juzgado`), `descripcionCircunscripcion` (no `circunscripcion`).
`NotificacionPendiente.__init__` ahora usa fallback `or` para ambos nombres.
- **PDF callback** — usaba `cod_notificacion_origen` como ID de actuación; corregido a
`cod_actuacion_caso` (campo `codActuacionCaso` de la API).
- **403 en `listar_notificaciones`** — manejado explícitamente como `CSJAuthError` en vez de
caer en `raise_for_status()` genérico.
---
## [0.2.0] — 2026-05-25
### Added
- **Multi-tenant** — `database.py` con SQLite vía aiosqlite; tabla `tenants` con credenciales
cifradas por chat_id; `list_active_tenants()`, `save_tenant()`, `delete_tenant()`.
- **Onboarding flow** (`onboarding.py`) — máquina de estados de 8 pasos:
código de invitación → consentimiento → nombre → tono → timezone → cédula → password → guardado.
- **Códigos de invitación** — tabla `invite_codes`, validación de un solo uso atómica,
fallback al código maestro del `.env`; `scripts/generar_codigo.py`.
- **Audit log** — tabla `audit_log`, función `audit.log()` fire-and-forget; cobertura de todos
los eventos de onboarding, polling, comandos y errores.
- **Rate limiting** (`utils/rate_limiter.py`) — ventana deslizante por chat_id, 20 req/60s;
reset en `/resetear`.
- **FERNET_KEY rotation** (`scripts/rotar_fernet_key.py`) — re-cifra todos los tenants de forma
atómica; hace backup de `.env` antes de modificar; aborta si algún tenant falla.
- Comando `/exp` — búsqueda de expedientes por número o carátula.
- Comando `/pdf` — descarga PDF de la actuación más reciente via `permisoFirmado`.
- Tono personalizable por abogado: formal / cotidiano / informal.
- Zona horaria configurable por abogado.
- `TELEGRAM_EXTRA_IDS` para autorizar IDs adicionales sin onboarding.
- `DEPLOY.md` — primera versión de la guía de despliegue en VPS Ubuntu con systemd.
### Changed
- `poller.py` itera sobre todos los tenants activos (era single-tenant).
- Snapshots por tenant: `data/snapshot_{chat_id}.json` (era `data/snapshot.json` global).
- `vault.py` usa derivación de clave Fernet por chat_id: `base64(SHA256(FERNET_KEY + str(chat_id)))`.
### Security
- Contraseña del PJ borrada de memoria inmediatamente tras cifrar (`del password` en onboarding).
- Mensaje de Telegram con la contraseña eliminado del chat vía `delete_message` antes de procesar.
---
## [0.1.0] — 2026-05-25
### Added
- Login al sistema judicial `apps.csj.gov.py`:
`GET /login` → cookie `cookiesession1``POST /autenticador/login` → bearer token (TTL 1h).
- Polling de `GET /Notificaciones/PorRecibir` cada N minutos en horario hábil (lun-vie, 7-18hs).
- Diff vs snapshot JSON local — solo notifica notificaciones con `codNotificacionOrigen` nuevo.
- Relogin automático transparente al expirar el token.
- Bot de Telegram con comandos: `/start`, `/notif`, `/estado`, `/ayuda`, `/salir`.
- Credenciales del PJ cifradas con Fernet en `.env` (`scripts/cifrar_credenciales.py`).
- `scrub()` en `utils/sanitize.py` — sanitiza bearer tokens, JWTs, cookies y passwords
antes de pasar cualquier dato externo a los logs.
- Logging estructurado con `structlog` (JSON en prod, ConsoleRenderer en dev vía `isatty`).
- Configuración vía `pydantic-settings` — único punto de config de la app (`config.py`).
- Stack base: `httpx`, `python-telegram-bot[ext] v21`, `aiosqlite`, `cryptography`, `structlog`,
`pydantic-settings`, `apscheduler`.
- Manejo de señales `SIGINT`/`SIGTERM` para shutdown limpio.

132
CLAUDE.md
View File

@@ -6,19 +6,21 @@ Pedrito Hechakuaa: un bot de Telegram que monitorea notificaciones pendientes
en el sistema judicial de Paraguay (`apps.csj.gov.py`) y avisa cuando aparece en el sistema judicial de Paraguay (`apps.csj.gov.py`) y avisa cuando aparece
algo nuevo. Uso propio de un solo abogado (single-tenant). algo nuevo. Uso propio de un solo abogado (single-tenant).
**Scope estricto de la v0:** **Versión actual: v0.3.1** — ver historial completo en `CHANGELOG.md`.
- Login al PJ + polling de notificaciones pendientes
- Diff vs snapshot anterior → solo notifica lo nuevo
- Telegram como canal de salida
- Correr en local o VPS con docker-compose
**Fuera del scope de v0 (no agregar sin consulta explícita):** **Scope implementado:**
- Login al PJ + polling de notificaciones pendientes (multi-tenant)
- Diff vs snapshot por tenant → solo notifica lo nuevo
- Telegram como canal de salida con onboarding, tono personalizable y timezone
- Búsqueda de expedientes y descarga de PDFs
- Infraestructura: Docker, health check HTTP, schema migrations, webhook opt-in
**Fuera del scope actual (no agregar sin consulta explícita):**
- Motor de reglas de plazos - Motor de reglas de plazos
- LLM / conversación libre - LLM / conversación libre
- Multi-tenant
- Web de onboarding - Web de onboarding
- Actuaciones de expedientes (es v1) - Actuaciones de expedientes (es v1)
- Acuse de notificaciones (nunca en MVP) - Acuse de notificaciones (nunca — efecto procesal irreversible)
--- ---
@@ -45,16 +47,55 @@ Toda llamada a `logger.*()` con datos externos pasa por acá.
## Arquitectura ## Arquitectura
```mermaid
graph TD
ABOGADO["👤 Abogado"]
TG(["Telegram API"])
CSJ(["apps.csj.gov.py · PJ"])
subgraph VPS ["VPS — Ubuntu 22.04"]
NGX["🔐 Nginx\nSSL · 30 r/s webhook · 10 r/min health\nX-Content-Type-Options · X-Frame-Options"]
subgraph DOCKER ["🐳 Docker container"]
WH["POST /webhook\n→ 404 si token inválido\nsecrets.compare_digest"]
HL["GET /health\npúblico: status + timestamp"]
HH["GET /health/history\nBearer auth · chat_id enmascarado SHA256"]
ONB["Onboarding FSM · 8 pasos\ninvite atómico UPDATE WHERE usado=0\ntiming ≥800ms · compare_digest"]
CMD["Comandos\n/notif · /exp · /pdf · /estado · /ayuda"]
POLLER["Poller · APScheduler\nmulti-tenant · lun-vie 7-18h"]
VAULT["🔐 vault.py\nPBKDF2-SHA256 600k iter\nDEK 32B por cifrado · envelope v3"]
CFG["🔐 config.py · Settings\nenv vars borradas post-init"]
SCRUB["scrub()\ntodo dato externo a logs\npasa por acá antes de escribir"]
end
DB[("pasante.db\ntenants · audit_log\ninvite_codes · migrations")]
SNAP["snapshot_{chat_id}.json\ndiff notificaciones por tenant"]
end
ABOGADO <-->|"HTTPS"| TG
TG -->|"HTTPS + X-Secret-Token"| NGX
NGX --> WH
NGX --> HL
NGX --> HH
WH --> ONB
WH --> CMD
ONB -->|"cifra credenciales"| VAULT
VAULT <-->|"credentials_enc"| DB
CMD --> DB
HH -->|"audit_log — chat_id enmascarado"| DB
POLLER -->|"HTTPS + Bearer + usuario-rol:16"| CSJ
CSJ -->|"notificaciones JSON"| POLLER
POLLER --> SNAP
POLLER -->|"alertas"| TG
POLLER -->|"descifra por tenant"| VAULT
CFG -.->|"configura"| VAULT
CFG -.->|"configura"| POLLER
classDef secure fill:#fef2f2,stroke:#991b1b,color:#450a0a
class NGX,VAULT,CFG,SCRUB secure
``` ```
main.py ← entrypoint, arranca bot + poller en paralelo
config.py ← Settings via pydantic-settings (ÚNICA fuente de config) Ver [SECURITY.md](SECURITY.md) para el diagrama de arquitectura de seguridad detallado.
csj_client.py ← HTTP client del PJ (login + notificaciones, read-only)
poller.py ← loop de polling + diff + dispara alertas
telegram_bot.py ← envía mensajes y maneja /comandos básicos
storage.py ← snapshot en JSON local (sin DB por ahora)
utils/
sanitize.py ← credential_scrubber() — NUNCA loggear sin pasar por acá
```
--- ---
@@ -66,11 +107,13 @@ utils/
| HTTP client | `httpx` async | | HTTP client | `httpx` async |
| Bot | `python-telegram-bot v21` async | | Bot | `python-telegram-bot v21` async |
| Scheduler | `APScheduler 3` | | Scheduler | `APScheduler 3` |
| Cifrado | `cryptography` (Fernet) | | Cifrado | `cryptography` (Fernet + PBKDF2) |
| HTTP server | `aiohttp>=3.9` (health check + webhook) |
| Config | `pydantic-settings` | | Config | `pydantic-settings` |
| Logging | `structlog` (JSON en prod, ConsoleRenderer en dev) | | Logging | `structlog` (JSON en prod, ConsoleRenderer en dev) |
| Infraestructura | Docker + docker-compose |
Sin Playwright. Sin Postgres. Sin Redis. Sin LLM. Sin Docker por ahora (opcional). Sin Playwright. Sin Postgres. Sin Redis. Sin LLM.
**No introducir librerías fuera de esta lista sin justificación explícita.** **No introducir librerías fuera de esta lista sin justificación explícita.**
@@ -253,9 +296,54 @@ Ante cambio de schema del PJ: actualizar `csj_client.py` y documentar en
| Archivo | Propósito | | Archivo | Propósito |
|---------|-----------| |---------|-----------|
| `CLAUDE.md` | Este archivo — reglas para Claude Code | | `CLAUDE.md` | Este archivo — reglas para Claude Code |
| `OBSERVATIONS.md` | Hallazgos durante el desarrollo (crear si no existe) | | `CHANGELOG.md` | Historial de versiones — actualizar antes de cada release |
| `TECH_DEBT.md` | Deuda técnica anotada (crear si no existe) | | `DEPLOY.md` | Guía de despliegue en VPS: Docker, Nginx, SSL, DNS |
| `data/snapshot.json` | Estado persistido del poller | | `OBSERVATIONS.md` | Hallazgos durante el desarrollo (schema real del PJ, etc.) |
| `TECH_DEBT.md` | Deuda técnica anotada |
| `migrations/` | Archivos `.sql` de schema — nunca editar los ya aplicados |
| `data/pasante.db` | Base de datos SQLite (tenants, audit_log, invite_codes) |
| `data/snapshot_*.json` | Estado del diff de notificaciones por tenant |
---
## Gestión de versiones
### Esquema: Semantic Versioning (semver 2.0)
`MAJOR.MINOR.PATCH` — tags git con prefijo `v` (ej: `v0.3.1`).
| Número | Incrementar cuando... | Ejemplos |
|--------|----------------------|---------|
| `MAJOR` | Cambio incompatible con versiones anteriores: migración de DB no retrocompatible, cambio de protocolo del PJ, rediseño de arquitectura | `v1.0.0` (migración a Postgres) |
| `MINOR` | Feature nuevo sin romper lo existente: comando nuevo, integración nueva, mejora visible al usuario | `/recordatorio`, modo silencioso |
| `PATCH` | Bug fix, security patch, mejora interna, documentación operacional | corrección de campo del PJ, hardening |
### Reglas que Claude Code sigue siempre
1. **Cada release requiere entrada en `CHANGELOG.md`** con fecha y secciones
`Added / Changed / Fixed / Security` según corresponda.
2. **El tag se crea después del commit de changelog**, no antes.
3. **`[Unreleased]`** acumula todos los cambios hasta que Marcos decide liberar.
4. **Claude propone el número de versión** basándose en las reglas de arriba;
Marcos lo aprueba antes de tagear.
5. **`git tag` y `git push --tags` los ejecuta Claude solo cuando Marcos lo confirma** —
son acciones externas (modifican el remoto) que requieren aprobación explícita.
6. **Tags retroactivos** son válidos para dar historia a commits anteriores;
se crean con `git tag -a vX.Y.Z <hash>`.
### Proceso de release (copy-paste)
```bash
# 1. CHANGELOG.md ya actualizado (mover [Unreleased] → [X.Y.Z] con fecha)
git add CHANGELOG.md
git commit -m "chore: release vX.Y.Z"
# 2. Tag anotado
git tag -a vX.Y.Z -m "vX.Y.Z — resumen en una línea"
# 3. Push incluyendo el tag
git push origin main --tags
```
--- ---

972
DEPLOY.md

File diff suppressed because it is too large Load Diff

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
WORKDIR /app
# Copiar archivos de dependencias primero (cache de layers)
COPY pyproject.toml uv.lock ./
# Instalar dependencias (sin el proyecto mismo todavía)
RUN uv sync --frozen --no-install-project
# Copiar el resto del código
COPY . .
# Instalar el proyecto
RUN uv sync --frozen
# Directorio de datos persistente (montar como volumen)
RUN mkdir -p /app/data && chown -R 1000:1000 /app
USER 1000
EXPOSE 8080
ENTRYPOINT [".venv/bin/python", "main.py"]

View File

@@ -23,6 +23,15 @@ para ver la estructura exacta del JSON y ajustar el parsing en `csj_client.py`.
--- ---
### Convención de URL en /actuaciones — corregido 19/06/2026
`listar_actuaciones()` usaba separador "/" entre codCasoJudicial y origen.
El PJ espera ":" para este endpoint (igual que en descargar_documento_principal()).
Bug detectado por beta tester via /pdf, confirmado en logs de producción.
Afectaba: /pdf y /exp (ambos llaman listar_actuaciones()).
---
## Bot de Telegram ## Bot de Telegram
*(agregar hallazgos acá)* *(agregar hallazgos acá)*

View File

@@ -0,0 +1,319 @@
# Prompt para Claude Code — Sprint 4: Features 3, 5 y 1
Lee `CLAUDE.md` antes de empezar. Las invariantes de seguridad aplican.
Implementar las tres features en orden: primero F5 (más simple), luego F3, luego F1.
---
## Feature 5 — Frases libres → comandos (sin LLM)
### Contexto
El bot hoy responde a texto libre con "no entendí eso, mirá /ayuda".
Hay frases obvias que debería mapear a comandos existentes sin necesidad de LLM.
### Implementación
En `telegram_bot.py`, modificar `handle_texto_libre()` para que antes de
responder "no entendí", intente mapear el texto a un comando conocido.
```python
FRASES_COMANDOS = {
# Notificaciones
"novedades": "notif",
"actualizaciones": "notif",
"que hay nuevo": "notif",
"qué hay nuevo": "notif",
"hay algo nuevo": "notif",
"alguna novedad": "notif",
"notificaciones": "notif",
"notificacion": "notif",
"notificación": "notif",
"revisar": "notif",
"actualizar": "notif",
# Estado
"estado": "estado",
"como estas": "estado",
"cómo estás": "estado",
# Ayuda
"ayuda": "ayuda",
"help": "ayuda",
"que podes hacer": "ayuda",
"qué podés hacer": "ayuda",
"comandos": "ayuda",
}
```
Lógica de matching: normalizar el texto del usuario (lowercase, sin acentos,
sin signos de puntuación) y buscar si alguna frase del diccionario está
contenida en el texto normalizado.
Si hay match → ejecutar el handler correspondiente directamente.
Si no hay match → respuesta actual ("no entendí eso...").
Casos especiales a detectar por regex:
- "expediente 198/2026" o "exp 198" → ejecutar cmd_exp con ese argumento
- "pdf 198/2026" → ejecutar cmd_pdf con ese argumento
### Verificación
- [ ] "novedades" ejecuta lo mismo que /notif
- [ ] "qué hay nuevo?" ejecuta lo mismo que /notif
- [ ] "expediente 198/2026" ejecuta lo mismo que /exp 198/2026
- [ ] Texto completamente irrelevante ("hola qué tal") sigue respondiendo con el mensaje de ayuda
- [ ] No interfiere con el flujo de onboarding
---
## Feature 3 — Extracto de PDF sin LLM
### Contexto
Cuando llega una notificación con documento adjunto, el abogado quiere saber
qué dice sin tener que descargar y abrir el PDF. La solución es extraer el
texto del PDF con `pypdf` y mostrar las primeras líneas como extracto.
Sin LLM. Sin interpretación. Texto crudo del documento con disclaimer.
### Nueva dependencia
Agregar a `pyproject.toml`:
```
"pypdf>=4.0",
```
### Nuevo módulo `pdf_extractor.py`
```python
"""
pdf_extractor.py — Extracción de texto de PDFs judiciales.
Sin LLM. Sin interpretación. Solo extracción de texto crudo.
El abogado lee el extracto y decide. Pedrito no interpreta.
Los PDFs nunca se guardan en disco — se procesan en memoria.
"""
from pypdf import PdfReader
import io
MAX_CHARS = 600 # máximo de caracteres a mostrar
def extraer_extracto(pdf_bytes: bytes) -> str | None:
"""
Extrae las primeras líneas de texto de un PDF judicial.
Retorna el extracto como string, o None si el PDF no tiene texto
extraíble (PDF escaneado, imagen, etc.)
Los PDFs del PJ son nativos (no escaneados), así que pypdf funciona.
"""
try:
reader = PdfReader(io.BytesIO(pdf_bytes))
texto = ""
for page in reader.pages:
texto += page.extract_text() or ""
if len(texto) >= MAX_CHARS:
break
texto = texto.strip()
if not texto:
return None
# Limpiar saltos de línea múltiples
import re
texto = re.sub(r'\n{3,}', '\n\n', texto)
if len(texto) > MAX_CHARS:
texto = texto[:MAX_CHARS] + "..."
return texto
except Exception:
return None
```
### Integración en el flujo de notificaciones
En `poller.py`, método `revisar_ahora()`, cuando se detecta una notificación
nueva y se va a enviar al abogado:
1. Intentar descargar el PDF (ya existe `descargar_documento_principal()`)
2. Si descarga OK → extraer extracto con `extraer_extracto()`
3. Enviar notificación con extracto incluido
4. Si descarga falla o PDF sin texto → enviar notificación sin extracto (degradación elegante)
### Formato del mensaje con extracto
```
📋 Nueva notificación judicial
{caratula}
Exp. {numero}/{anio} — {juzgado}
Tipo: {tipo}
Fecha: {fecha}
📄 Extracto del documento:
{extracto}
⚠️ Este es el texto crudo del documento. Verificá
el original antes de actuar.
[Ver PDF] [Ir al sistema del PJ]
```
Si no hay extracto disponible, el mensaje es igual pero sin la sección
"📄 Extracto del documento".
### En `cmd_pdf()` de `telegram_bot.py`
Cuando el abogado pide `/pdf 198/2026`, después de enviar el archivo también
enviar el extracto como mensaje de texto separado:
```
📄 Contenido del documento:
{extracto}
⚠️ Verificá el original antes de actuar.
```
### Verificación
- [ ] Una notificación nueva llega con el extracto incluido
- [ ] Si el PDF no tiene texto extraíble, la notificación llega igual sin extracto
- [ ] El extracto no supera 600 caracteres
- [ ] El disclaimer siempre aparece cuando hay extracto
- [ ] Los bytes del PDF nunca se escriben a disco
- [ ] `scrub()` en cualquier log que mencione el contenido del extracto
- [ ] Agregar `pypdf` a `pyproject.toml`
---
## Feature 1 — Backend de administración web (FastAPI + HTML puro)
### Contexto
Panel de administración interno para gestionar tenants y ver actividad.
Solo para el admin (Marcos). No es un producto — es una herramienta operativa.
HTML puro servido por FastAPI. Sin framework frontend. Funcional sobre bonito.
### Nueva dependencia
Agregar a `pyproject.toml`:
```
"fastapi>=0.115",
"uvicorn>=0.32",
"jinja2>=3.1",
```
### Estructura de archivos nuevos
```
admin/
__init__.py
app.py ← FastAPI app del panel
auth.py ← autenticación básica (token en .env)
templates/
base.html ← layout común
tenants.html ← listado de tenants
tenant.html ← detalle de un tenant
audit.html ← audit log
```
### Autenticación del panel
Token estático en `.env`:
```
ADMIN_TOKEN=genera-uno-con-secrets.token_hex(32)
```
Middleware simple: si el request no tiene el header `X-Admin-Token` con el
valor correcto → 403. No hace falta sesiones ni cookies para este sprint.
Agregar a `Settings` en `config.py`:
```python
admin_token: str = ""
admin_port: int = 8080
```
### Rutas del panel
```
GET / → redirect a /tenants
GET /tenants → listado de todos los tenants
GET /tenants/{id} → detalle de un tenant
POST /tenants/{id}/resetear → resetea el tenant (borra DB + snapshot)
GET /audit → audit log, últimas 200 entradas, filtrable por chat_id
GET /audit?chat_id=X → audit log filtrado por tenant
```
### Página /tenants — tabla con columnas
| chat_id | nombre | estado | tono | timezone | cédula (primeros 3 dígitos + ***) | último chequeo | acciones |
|---------|--------|--------|------|----------|-----------------------------------|----------------|---------|
La cédula se muestra parcialmente — nunca en texto plano.
"Acciones" tiene un botón "Resetear" que hace POST a `/tenants/{id}/resetear`.
### Página /tenants/{id} — detalle
- Todos los campos del tenant
- Últimas 20 entradas del audit_log para ese chat_id
- Botón resetear
### Página /audit — tabla con columnas
| timestamp (hora local) | chat_id | nombre tenant | evento | resultado | detalle (JSON colapsado) |
Filtro por chat_id con un input de texto simple.
Paginación básica: 200 registros por página, botón "más antiguos".
### Arrancar el panel junto con el bot
En `main.py`, arrancar el servidor de administración en un thread separado:
```python
import uvicorn
import threading
def run_admin():
uvicorn.run("admin.app:app", host="127.0.0.1", port=settings.admin_port, log_level="warning")
admin_thread = threading.Thread(target=run_admin, daemon=True)
admin_thread.start()
```
El panel corre en `127.0.0.1:8080` — solo accesible via SSH tunnel, no expuesto públicamente.
Para acceder desde tu Mac:
```bash
ssh -L 8080:127.0.0.1:8080 pedrito@<IP_VPS>
# Luego abrir http://localhost:8080 en el browser
```
### Seguridad del panel
- Solo escucha en 127.0.0.1 (no en 0.0.0.0) — inaccesible desde internet
- Requiere el token en cada request
- Las cédulas nunca se muestran completas
- Los bearer tokens del PJ nunca aparecen en el panel
- El panel es de solo lectura excepto la acción "resetear"
### Verificación
- [ ] `GET /tenants` muestra los tenants registrados con cédula parcial
- [ ] `POST /tenants/{id}/resetear` borra el tenant y su snapshot
- [ ] `GET /audit` muestra las últimas 200 entradas
- [ ] `GET /audit?chat_id=X` filtra correctamente
- [ ] Sin `ADMIN_TOKEN` en el request → 403
- [ ] El panel NO es accesible desde internet (solo 127.0.0.1)
- [ ] El panel arranca junto con el bot sin errores
- [ ] Agregar `fastapi`, `uvicorn`, `jinja2` a `pyproject.toml`
---
## Orden de implementación sugerido
1. Feature 5 (frases libres) — 2-3 horas, sin dependencias nuevas
2. Feature 3 (extracto PDF) — 3-4 horas, una dependencia nueva
3. Feature 1 (panel admin) — 4-6 horas, más compleja
## Lo que NO hacer en este sprint
- No usar LLM para nada — ni resumen, ni clasificación, ni mapeo de intenciones
- No exponer el panel en un puerto público
- No guardar PDFs en disco
- No mostrar cédulas completas en el panel
- No cambiar el onboarding
- No cambiar el flujo de notificaciones existente más allá de agregar el extracto

179
SECURITY.md Normal file
View File

@@ -0,0 +1,179 @@
# Arquitectura de seguridad — Pedrito Hechakuaa
Versión actual: **v0.4.1**. Ver [CHANGELOG.md](CHANGELOG.md) para historial.
---
## 1. Defensa en profundidad
Cada vector de ataque identificado tiene al menos un control en la capa de red,
uno en la aplicación, y uno en los datos.
```mermaid
graph LR
subgraph THREATS ["Vectores de ataque"]
T1["🌐 Red pública\nport scan · DDoS · flood"]
T2["📨 Webhook spoofing\nfake Telegram updates"]
T3["🔍 Health endpoint\nexfiltración de datos internos"]
T4["🔑 Onboarding\nbrute-force de códigos de invitación"]
T5["💾 Robo de DB\ncredenciales en texto plano"]
T6["🧠 Acceso a proceso\n/proc/pid/environ · docker inspect"]
T7["👤 Usuario no registrado\nreconocimiento de comandos"]
end
subgraph DEFENSES ["Controles implementados"]
D1["UFW: solo 22/80/443\nNginx rate-limit 30r/s webhook\n10r/min health · security headers"]
D2["X-Secret-Token\nsecrets.compare_digest\n→ 404 si falla (no confirma endpoint)"]
D3["Bearer auth en /history\nchat_id enmascarado SHA256[:8]\n/health público devuelve mínimo"]
D4["UPDATE atómico WHERE usado=0\ntiming mínimo ≥800ms\nsecrets.compare_digest vs master code"]
D5["vault.py v3: PBKDF2 + DEK/KEK\nrobar DB sin FERNET_KEY = inútil\nrobar FERNET_KEY sin DB = inútil"]
D6["os.environ.pop() post-init\nFERNET_KEY · tokens · secrets borrados\n(protege /proc · no docker inspect)"]
D7["Silencio total a desconocidos\nallowlist por chat_id\nrespuestas mínimas no revelan arquitectura"]
end
T1 --> D1
T2 --> D2
T3 --> D3
T4 --> D4
T5 --> D5
T6 --> D6
T7 --> D7
classDef threat fill:#fef2f2,stroke:#991b1b,color:#450a0a
classDef defense fill:#f0fdf4,stroke:#166534,color:#052e16
class T1,T2,T3,T4,T5,T6,T7 threat
class D1,D2,D3,D4,D5,D6,D7 defense
```
---
## 2. Cifrado de credenciales — vault.py v3
El sistema usa **envelope encryption** en dos capas. Comprometer una capa sola
no es suficiente para obtener credenciales en texto plano.
```mermaid
graph TD
subgraph INPUT ["Entrada"]
FKEY["FERNET_KEY\n(del .env — jamás toca disco como texto)"]
CREDS["usuario + password\n(del abogado en Telegram)"]
end
subgraph GEN ["Generación de material criptográfico"]
SALT["salt = os.urandom(16)\n16 bytes aleatorios\núnico por cada operación de cifrado"]
DEK["dek = os.urandom(32)\n32 bytes aleatorios\núnico por cada operación de cifrado"]
end
subgraph KEK_LAYER ["Capa KEK — derivación de clave wrapping"]
PBKDF2["PBKDF2-HMAC-SHA256\n600.000 iteraciones · ~200ms\nwrapping_key = PBKDF2(FERNET_KEY, salt)\nfuerza bruta inviable incluso con GPU"]
WRAP["dek_enc = Fernet(wrapping_key).encrypt(dek)\nla DEK queda protegida por la clave derivada"]
end
subgraph DEK_LAYER ["Capa DEK — cifrado de datos"]
PAYLOAD["payload = JSON con usuario + password"]
PENC["payload_enc = Fernet(dek).encrypt(payload)\nlos datos quedan protegidos por clave efímera"]
end
subgraph STORED ["Guardado en DB — credentials_enc"]
JSON_OUT["{v:3, s:salt_b64, k:dek_enc_b64, p:payload_enc_b64}"]
end
FKEY --> PBKDF2
SALT --> PBKDF2
PBKDF2 --> WRAP
DEK --> WRAP
DEK --> PENC
CREDS --> PAYLOAD --> PENC
WRAP --> JSON_OUT
PENC --> JSON_OUT
SALT --> JSON_OUT
classDef input fill:#eff6ff,stroke:#1d4ed8,color:#1e3a8a
classDef crypto fill:#f0fdf4,stroke:#166534,color:#052e16
classDef stored fill:#fefce8,stroke:#854d0e,color:#451a03
class FKEY,CREDS input
class SALT,DEK,PBKDF2,WRAP,PAYLOAD,PENC crypto
class JSON_OUT stored
```
### Por qué dos capas
| Atacante tiene acceso a... | Puede obtener credenciales? |
|----------------------------|-----------------------------|
| Solo la DB (`pasante.db`) | No — DEK y payload cifrados con clave derivada de FERNET_KEY |
| Solo `FERNET_KEY` | No — el salt está en la DB; sin él no se puede derivar la wrapping key |
| DB + `FERNET_KEY` | Sí — pero requiere acceso completo al servidor |
| Una credencial descifrada | No afecta las demás — cada tenant tiene salt y DEK únicos |
---
## 3. Ciclo de vida de credenciales
```mermaid
sequenceDiagram
actor Abogado
participant ONB as Onboarding FSM
participant BOT as telegram_bot
participant VAULT as vault.py
participant DB as pasante.db
participant POLL as Poller
participant CSJ as apps.csj.gov.py
Note over Abogado,DB: — Alta de abogado —
Abogado->>BOT: envía contraseña por Telegram
BOT->>BOT: delete_message() — borra el mensaje del chat
BOT->>ONB: procesa credencial
ONB->>VAULT: cifrar_credenciales(chat_id, usuario, password, fernet_key)
Note over VAULT: genera salt (16B) + DEK (32B) aleatorios
Note over VAULT: PBKDF2(FERNET_KEY, salt, 600k iter) → wrapping_key
Note over VAULT: Fernet(wrapping_key).encrypt(DEK) → dek_enc
Note over VAULT: Fernet(DEK).encrypt(payload) → payload_enc
VAULT-->>ONB: JSON envelope v3
ONB->>DB: UPDATE tenants SET credentials_enc = ?
Note over ONB: del password — limpieza de memoria inmediata
Note over Abogado,CSJ: — Ciclo de polling (cada hora) —
POLL->>DB: SELECT credentials_enc FROM tenants WHERE estado='activo'
POLL->>VAULT: descifrar_credenciales(chat_id, credentials_enc, fernet_key)
Note over VAULT: lru_cache(wrapping_key) si mismo (salt, fernet_key) ya derivado
Note over VAULT: Fernet(wrapping_key).decrypt(dek_enc) → DEK
Note over VAULT: Fernet(DEK).decrypt(payload_enc) → usuario, password
VAULT-->>POLL: (usuario, password) — solo en memoria
POLL->>CSJ: POST /autenticador/login {usuario, clave}
CSJ-->>POLL: bearerToken (TTL 1h)
Note over POLL: del usuario, password — limpieza de memoria
POLL->>CSJ: GET /Notificaciones/PorRecibir (Bearer)
CSJ-->>POLL: lista de notificaciones
POLL->>Abogado: alerta Telegram si hay novedades
```
---
## 4. Controles transversales
| Control | Implementación | Archivo |
|---------|---------------|---------|
| Log sanitization | `scrub()` — todo dato externo pasa por acá antes de `logger.*()` | `utils/sanitize.py` |
| Audit trail | `audit.log()` — quién, qué, cuándo, resultado — sin payload sensible | `audit.py` |
| Rate limiting (HTTP) | `limit_req_zone` en Nginx — 30r/s webhook, 10r/min health | `DEPLOY.md § 11` |
| Rate limiting (bot) | Ventana deslizante 20 req/60s por chat_id | `utils/rate_limiter.py` |
| Comparación de secrets | `secrets.compare_digest()` — evita timing oracle en todas las validaciones | `health.py`, `onboarding.py` |
| Env vars post-init | `os.environ.pop()` para FERNET_KEY, tokens y secrets tras cargar Settings | `main.py` |
| Invite codes | `UPDATE WHERE usado=0` atómico — elimina TOCTOU race condition | `database.py` |
| chat_id en logs | Enmascarado como `usr_` + SHA256[:8] — no reversible, sí correlacionable | `health.py` |
| Respuestas a desconocidos | Silencio total; `/health` público devuelve solo `{status, timestamp}` | `telegram_bot.py`, `health.py` |
| Acuse de notificaciones | `PUT /Notificaciones/{id}/recibir` **NUNCA se llama** — efecto procesal irreversible | invariante global |
---
## 5. Limitaciones conocidas
| Limitación | Motivo | Mitigación |
|------------|--------|------------|
| `docker inspect` expone env vars | Docker no puede evitarlo por diseño | Usar Docker secrets en v1; FERNET_KEY en vault externo (HashiCorp Vault) |
| Credenciales en memoria no se pueden zerear | Python `str` es inmutable; GC no garantizado | `del` inmediato post-uso; sin referencias persistentes |
| lru_cache mantiene wrapping keys en RAM | Tradeoff performance vs isolation | Cache key incluye fingerprint del KEK; se invalida si cambia FERNET_KEY |
| `.env` en disco del servidor | Necesario para arrancar | Permisos `600`, usuario dedicado `pedrito`, fuera del directorio web |
| Backup de DB sin cifrar | `data/pasante.db.bak` queda en disco | Incluir en política de backup cifrado; mismos permisos que la DB |

0
admin/__init__.py Normal file
View File

159
admin/app.py Normal file
View File

@@ -0,0 +1,159 @@
"""
admin/app.py — Panel de administración interno (FastAPI).
Solo escucha en 127.0.0.1. Acceso via SSH tunnel:
ssh -L 9090:127.0.0.1:9090 pedrito@<IP_VPS>
Luego: http://localhost:9090
Autenticación: header X-Admin-Token en cada request.
"""
from __future__ import annotations
import json
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import aiosqlite
from fastapi import Depends, FastAPI, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from admin.auth import require_token
_db_path: str = ""
_admin_token: str = ""
_data_dir: Path = Path("./data")
templates = Jinja2Templates(directory="admin/templates")
@asynccontextmanager
async def _lifespan(app: FastAPI):
app.state.db = await aiosqlite.connect(_db_path)
app.state.db.row_factory = aiosqlite.Row
yield
await app.state.db.close()
def create_app(db_path: str, admin_token: str, data_dir: Path) -> FastAPI:
global _db_path, _admin_token, _data_dir
_db_path = db_path
_admin_token = admin_token
_data_dir = data_dir
app = FastAPI(lifespan=_lifespan, docs_url=None, redoc_url=None)
auth = Depends(require_token(admin_token))
# ── helpers ───────────────────────────────────────────────────────────────
def _mask_cedula(credentials_enc: str | None) -> str:
"""Muestra '***' — la cédula está cifrada y no se expone en el panel."""
if not credentials_enc:
return ""
return "***"
def _parse_detalle(detalle_json: str | None) -> str:
if not detalle_json:
return ""
try:
return json.dumps(json.loads(detalle_json), ensure_ascii=False, indent=None)
except Exception:
return detalle_json or ""
# ── rutas ─────────────────────────────────────────────────────────────────
@app.get("/", include_in_schema=False)
async def root():
return RedirectResponse(url="/tenants")
@app.get("/tenants", response_class=HTMLResponse, dependencies=[auth])
async def lista_tenants(request: Request):
db: aiosqlite.Connection = request.app.state.db
async with db.execute("SELECT * FROM tenants ORDER BY created_at DESC") as cur:
rows = [dict(r) for r in await cur.fetchall()]
for r in rows:
r["cedula_masked"] = _mask_cedula(r.get("credentials_enc"))
r.pop("credentials_enc", None)
return templates.TemplateResponse("tenants.html", {"request": request, "tenants": rows})
@app.get("/tenants/{chat_id}", response_class=HTMLResponse, dependencies=[auth])
async def detalle_tenant(request: Request, chat_id: int):
db: aiosqlite.Connection = request.app.state.db
async with db.execute("SELECT * FROM tenants WHERE chat_id = ?", (chat_id,)) as cur:
row = await cur.fetchone()
if not row:
return HTMLResponse("<h1>Tenant no encontrado</h1>", status_code=404)
tenant: dict[str, Any] = dict(row)
tenant["cedula_masked"] = _mask_cedula(tenant.get("credentials_enc"))
tenant.pop("credentials_enc", None)
async with db.execute(
"SELECT * FROM audit_log WHERE chat_id = ? ORDER BY timestamp DESC LIMIT 20",
(chat_id,),
) as cur:
logs = [dict(r) for r in await cur.fetchall()]
for entry in logs:
entry["detalle_str"] = _parse_detalle(entry.get("detalle"))
return templates.TemplateResponse(
"tenant.html", {"request": request, "tenant": tenant, "logs": logs}
)
@app.post("/tenants/{chat_id}/resetear", dependencies=[auth])
async def resetear_tenant(request: Request, chat_id: int):
db: aiosqlite.Connection = request.app.state.db
await db.execute("DELETE FROM tenants WHERE chat_id = ?", (chat_id,))
await db.execute("DELETE FROM audit_log WHERE chat_id = ?", (chat_id,))
await db.commit()
snapshot = _data_dir / f"snapshot_{chat_id}.json"
if snapshot.exists():
snapshot.unlink()
return RedirectResponse(url="/tenants", status_code=303)
@app.get("/audit", response_class=HTMLResponse, dependencies=[auth])
async def audit_log(request: Request, chat_id: str = "", page: int = 1):
db: aiosqlite.Connection = request.app.state.db
limit = 200
offset = (page - 1) * limit
if chat_id.strip():
try:
cid = int(chat_id.strip())
async with db.execute(
"SELECT * FROM audit_log WHERE chat_id = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
(cid, limit, offset),
) as cur:
logs = [dict(r) for r in await cur.fetchall()]
async with db.execute(
"SELECT COUNT(*) FROM audit_log WHERE chat_id = ?", (cid,)
) as cur:
total = (await cur.fetchone())[0]
except ValueError:
logs, total = [], 0
else:
async with db.execute(
"SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT ? OFFSET ?",
(limit, offset),
) as cur:
logs = [dict(r) for r in await cur.fetchall()]
async with db.execute("SELECT COUNT(*) FROM audit_log") as cur:
total = (await cur.fetchone())[0]
for entry in logs:
entry["detalle_str"] = _parse_detalle(entry.get("detalle"))
has_more = (offset + limit) < total
return templates.TemplateResponse(
"audit.html",
{
"request": request,
"logs": logs,
"chat_id_filter": chat_id,
"page": page,
"has_more": has_more,
"total": total,
},
)
return app

22
admin/auth.py Normal file
View File

@@ -0,0 +1,22 @@
"""
admin/auth.py — Autenticación del panel de administración.
Token estático via header X-Admin-Token.
Si ADMIN_TOKEN no está configurado, el panel rechaza todos los requests.
"""
from __future__ import annotations
import secrets
from fastapi import HTTPException, Request
def require_token(token_esperado: str):
"""Devuelve una dependencia FastAPI que valida X-Admin-Token."""
async def _check(request: Request) -> None:
if not token_esperado:
raise HTTPException(status_code=403, detail="Panel no configurado: falta ADMIN_TOKEN")
token = request.headers.get("X-Admin-Token", "")
if not secrets.compare_digest(token, token_esperado):
raise HTTPException(status_code=403, detail="Token inválido")
return _check

View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %} — Audit log{% endblock %}
{% block content %}
<h1>Audit log ({{ total }} registros)</h1>
<form class="filter-bar" method="get" action="/audit">
<input type="text" name="chat_id" value="{{ chat_id_filter }}" placeholder="Filtrar por chat_id">
<button type="submit" class="btn btn-primary">Filtrar</button>
{% if chat_id_filter %}<a href="/audit" class="btn">Limpiar</a>{% endif %}
</form>
<table>
<thead>
<tr>
<th>Timestamp (UTC)</th>
<th>chat_id</th>
<th>Evento</th>
<th>Resultado</th>
<th>Detalle</th>
</tr>
</thead>
<tbody>
{% for entry in logs %}
<tr>
<td style="white-space:nowrap">{{ (entry.timestamp or "")[:19] }}</td>
<td>
{% if entry.chat_id %}
<a href="/tenants/{{ entry.chat_id }}">{{ entry.chat_id }}</a>
{% else %}—{% endif %}
</td>
<td><code>{{ entry.evento }}</code></td>
<td>{{ entry.resultado or "—" }}</td>
<td style="max-width:350px;word-break:break-all"><code>{{ entry.detalle_str }}</code></td>
</tr>
{% else %}
<tr><td colspan="5" style="text-align:center;color:#999">Sin registros</td></tr>
{% endfor %}
</tbody>
</table>
<div class="pagination">
{% if page > 1 %}
<a href="/audit?chat_id={{ chat_id_filter }}&page={{ page - 1 }}" class="btn">← Más recientes</a>
{% endif %}
<span>Página {{ page }}</span>
{% if has_more %}
<a href="/audit?chat_id={{ chat_id_filter }}&page={{ page + 1 }}" class="btn">Más antiguos →</a>
{% endif %}
</div>
{% endblock %}

48
admin/templates/base.html Normal file
View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pedrito Admin{% block title %}{% endblock %}</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f5f5; color: #222; }
nav { background: #1a1a2e; color: #fff; padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1.5rem; }
nav strong { font-size: 1.1rem; color: #e2e2ff; }
nav a { color: #adb5ff; text-decoration: none; font-size: 0.95rem; }
nav a:hover { color: #fff; }
main { max-width: 1100px; margin: 1.5rem auto; padding: 0 1rem; }
h1 { font-size: 1.4rem; margin-bottom: 1rem; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
th { background: #e8e8f0; text-align: left; padding: 0.6rem 0.8rem; font-size: 0.85rem; color: #555; }
td { padding: 0.55rem 0.8rem; border-top: 1px solid #eee; font-size: 0.9rem; vertical-align: top; }
tr:hover td { background: #fafafa; }
.badge { display: inline-block; padding: 0.2rem 0.5rem; border-radius: 3px; font-size: 0.8rem; }
.badge-activo { background: #d4edda; color: #155724; }
.badge-pendiente { background: #fff3cd; color: #856404; }
.badge-suspendido { background: #f8d7da; color: #721c24; }
.btn { display: inline-block; padding: 0.35rem 0.75rem; border-radius: 4px; font-size: 0.85rem; text-decoration: none; border: none; cursor: pointer; }
.btn-primary { background: #4a6cf7; color: #fff; }
.btn-danger { background: #dc3545; color: #fff; }
.btn-sm { padding: 0.2rem 0.5rem; font-size: 0.8rem; }
form { display: inline; }
code { font-size: 0.8rem; background: #f0f0f0; padding: 1px 4px; border-radius: 3px; }
.detail-grid { display: grid; grid-template-columns: 180px 1fr; gap: 0.4rem 1rem; background: #fff; padding: 1rem 1.2rem; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,.1); margin-bottom: 1.5rem; }
.detail-grid dt { font-weight: 600; font-size: 0.85rem; color: #666; }
.detail-grid dd { margin: 0; font-size: 0.9rem; }
.filter-bar { display: flex; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
.filter-bar input { padding: 0.4rem 0.7rem; border: 1px solid #ccc; border-radius: 4px; font-size: 0.9rem; }
.pagination { margin-top: 1rem; display: flex; gap: 0.5rem; align-items: center; font-size: 0.9rem; }
</style>
</head>
<body>
<nav>
<strong>⚖️ Pedrito Admin</strong>
<a href="/tenants">Tenants</a>
<a href="/audit">Audit log</a>
</nav>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %} — Tenant {{ tenant.chat_id }}{% endblock %}
{% block content %}
<h1>Tenant {{ tenant.chat_id }}</h1>
<dl class="detail-grid">
<dt>chat_id</dt> <dd>{{ tenant.chat_id }}</dd>
<dt>Nombre</dt> <dd>{{ tenant.nombre_preferido or "—" }}</dd>
<dt>Estado</dt> <dd><span class="badge badge-{{ tenant.estado or 'pendiente' }}">{{ tenant.estado or "pendiente" }}</span></dd>
<dt>Tono</dt> <dd>{{ tenant.tono or "—" }}</dd>
<dt>Timezone</dt> <dd>{{ tenant.timezone or "—" }}</dd>
<dt>Cédula</dt> <dd><code>{{ tenant.cedula_masked }}</code></dd>
<dt>Creado</dt> <dd>{{ (tenant.created_at or "")[:19] }}</dd>
<dt>Último activo</dt><dd>{{ (tenant.last_active or "")[:19] }}</dd>
</dl>
<form method="post" action="/tenants/{{ tenant.chat_id }}/resetear"
onsubmit="return confirm('¿Resetear tenant {{ tenant.chat_id }}? Esta acción borra el tenant y su snapshot.')">
<button type="submit" class="btn btn-danger">Resetear tenant</button>
</form>
<h2 style="margin-top:2rem;font-size:1.1rem">Últimas 20 entradas del audit log</h2>
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Evento</th>
<th>Resultado</th>
<th>Detalle</th>
</tr>
</thead>
<tbody>
{% for entry in logs %}
<tr>
<td style="white-space:nowrap">{{ (entry.timestamp or "")[:19] }}</td>
<td><code>{{ entry.evento }}</code></td>
<td>{{ entry.resultado or "—" }}</td>
<td style="max-width:400px;word-break:break-all"><code>{{ entry.detalle_str }}</code></td>
</tr>
{% else %}
<tr><td colspan="4" style="text-align:center;color:#999">Sin registros</td></tr>
{% endfor %}
</tbody>
</table>
<p style="margin-top:1rem"><a href="/tenants">← Volver a tenants</a></p>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %} — Tenants{% endblock %}
{% block content %}
<h1>Tenants ({{ tenants|length }})</h1>
<table>
<thead>
<tr>
<th>chat_id</th>
<th>Nombre</th>
<th>Estado</th>
<th>Tono</th>
<th>Timezone</th>
<th>Cédula</th>
<th>Último activo</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for t in tenants %}
<tr>
<td><a href="/tenants/{{ t.chat_id }}">{{ t.chat_id }}</a></td>
<td>{{ t.nombre_preferido or "—" }}</td>
<td><span class="badge badge-{{ t.estado or 'pendiente' }}">{{ t.estado or "pendiente" }}</span></td>
<td>{{ t.tono or "—" }}</td>
<td>{{ t.timezone or "—" }}</td>
<td><code>{{ t.cedula_masked }}</code></td>
<td>{{ (t.last_active or "")[:19] }}</td>
<td>
<a href="/tenants/{{ t.chat_id }}" class="btn btn-primary btn-sm">Ver</a>
<form method="post" action="/tenants/{{ t.chat_id }}/resetear"
onsubmit="return confirm('¿Resetear tenant {{ t.chat_id }}? Esta acción borra el tenant y su snapshot.')">
<button type="submit" class="btn btn-danger btn-sm">Resetear</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="8" style="text-align:center;color:#999">Sin tenants registrados</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -57,6 +57,22 @@ class Settings(BaseSettings):
# Código de invitación para registro (vacío = registro abierto) # Código de invitación para registro (vacío = registro abierto)
invite_code: str = "" invite_code: str = ""
# Token para los endpoints HTTP protegidos (/health detallado, /health/history)
# Sin auth configurada los endpoints devuelven respuesta mínima (status only)
health_auth_token: str = ""
# Webhook (vacío = long-polling)
webhook_url: str = "" # ej: https://bot.midominio.com
webhook_secret: str = "" # token secreto para X-Telegram-Bot-Api-Secret-Token
# Health check HTTP
health_port: int = 8080
health_alert_hours: int = 2 # alerta al admin si el poller no actualizó en N horas
# Panel de administración (solo 127.0.0.1, acceso via SSH tunnel)
admin_token: str = "" # generar con: python -c "import secrets; print(secrets.token_hex(32))"
admin_port: int = 9090 # health_port usa 8080; admin usa puerto separado
# Logging # Logging
log_level: str = "INFO" log_level: str = "INFO"

View File

@@ -597,15 +597,16 @@ class CSJClient:
""" """
Retorna las actuaciones de un expediente. Retorna las actuaciones de un expediente.
URL confirmada por reconocimiento 22/05/2026: URL confirmada en logs de producción 19/06/2026:
GET /api/gestion-partes/CasoJudicial/{codCasoJudicial}/{origen}/0/actuaciones GET /api/gestion-partes/CasoJudicial/{codCasoJudicial}/{origen}:0/actuaciones
El segundo posicional (/0/) es fijo en todos los casos observados. El PJ usa ":" como separador entre cod_caso_judicial y origen
(igual que en descargar_documento_principal()), no "/".
""" """
session = await self._asegurar_sesion() session = await self._asegurar_sesion()
assert self._http is not None assert self._http is not None
url = f"/api/gestion-partes/CasoJudicial/{cod_caso_judicial}/{origen}/0/actuaciones" url = f"/api/gestion-partes/CasoJudicial/{cod_caso_judicial}/{origen}:0/actuaciones"
params: dict[str, Any] = { params: dict[str, Any] = {
"offset": offset, "offset": offset,
"cantidad": cantidad, "cantidad": cantidad,

View File

@@ -6,6 +6,8 @@ SQLite vía aiosqlite. En v1 esto se reemplaza por Postgres con el mismo interfa
Schema: Schema:
- tenants: un registro por abogado registrado. - tenants: un registro por abogado registrado.
Las credenciales van cifradas con vault.py (Fernet derivada por chat_id). Las credenciales van cifradas con vault.py (Fernet derivada por chat_id).
- Migraciones: directorio migrations/*.sql, ordenadas por nombre.
schema_migrations lleva registro de las ya aplicadas.
""" """
from __future__ import annotations from __future__ import annotations
@@ -19,49 +21,9 @@ logger = structlog.get_logger(__name__)
_conn: aiosqlite.Connection | None = None _conn: aiosqlite.Connection | None = None
_CREATE_AUDIT_LOG = """
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
timestamp_local TEXT,
chat_id INTEGER,
evento TEXT NOT NULL,
detalle TEXT,
resultado TEXT,
duracion_ms INTEGER
);
CREATE INDEX IF NOT EXISTS idx_audit_chat_id ON audit_log(chat_id);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_evento ON audit_log(evento);
"""
_CREATE_INVITE_CODES = """
CREATE TABLE IF NOT EXISTS invite_codes (
codigo TEXT PRIMARY KEY,
creado_at TEXT NOT NULL,
usado INTEGER DEFAULT 0,
usado_por INTEGER,
usado_at TEXT
);
"""
_CREATE_TENANTS = """
CREATE TABLE IF NOT EXISTS tenants (
chat_id INTEGER PRIMARY KEY,
nombre_preferido TEXT,
tono TEXT DEFAULT 'cotidiano',
timezone TEXT DEFAULT 'America/Asuncion',
credentials_enc TEXT,
estado TEXT DEFAULT 'pendiente',
consentimiento_aceptado INTEGER DEFAULT 0,
created_at TEXT,
last_active TEXT
);
"""
def get_conn() -> aiosqlite.Connection: def get_conn() -> aiosqlite.Connection:
"""Retorna la conexión activa. Llamar solo después de init_db().""" """Retorna la conexión activa. Llamar solo después de run_migrations()."""
return _assert_conn() return _assert_conn()
@@ -69,23 +31,57 @@ def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
async def init_db(db_path: Path) -> None: async def run_migrations(db_path: Path) -> None:
"""Crear tablas si no existen. Llamar una sola vez al arrancar.""" """
Aplica migraciones pendientes desde migrations/*.sql en orden alfabético.
Idempotente: versiones ya aplicadas se saltean.
Reemplaza init_db() — llamar una sola vez al arrancar.
"""
global _conn global _conn
db_path.parent.mkdir(parents=True, exist_ok=True) db_path.parent.mkdir(parents=True, exist_ok=True)
_conn = await aiosqlite.connect(str(db_path)) _conn = await aiosqlite.connect(str(db_path))
_conn.row_factory = aiosqlite.Row _conn.row_factory = aiosqlite.Row
await _conn.execute(_CREATE_INVITE_CODES)
await _conn.execute(_CREATE_TENANTS) await _conn.execute("""
# CREATE INDEX no soporta executescript — ejecutar sentencias separadas CREATE TABLE IF NOT EXISTS schema_migrations (
for stmt in _CREATE_AUDIT_LOG.strip().split(";"): version TEXT PRIMARY KEY,
stmt = stmt.strip() applied_at TEXT NOT NULL
if stmt: )
await _conn.execute(stmt) """)
await _conn.commit() await _conn.commit()
async with _conn.execute("SELECT version FROM schema_migrations") as cur:
applied = {row["version"] for row in await cur.fetchall()}
migrations_dir = Path(__file__).parent / "migrations"
for migration_file in sorted(migrations_dir.glob("*.sql")):
version = migration_file.stem
if version in applied:
logger.debug("migration_ya_aplicada", version=version)
continue
sql = migration_file.read_text(encoding="utf-8")
for stmt in sql.split(";"):
stmt = stmt.strip()
# Saltear líneas de comentario puras y sentencias vacías
if stmt and not all(line.startswith("--") for line in stmt.splitlines() if line.strip()):
await _conn.execute(stmt)
await _conn.execute(
"INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)",
(version, _now_iso()),
)
await _conn.commit()
logger.info("migration_aplicada", version=version)
logger.info("database_iniciada", path=str(db_path)) logger.info("database_iniciada", path=str(db_path))
async def init_db(db_path: Path) -> None:
"""Alias de run_migrations() para compatibilidad con scripts existentes."""
await run_migrations(db_path)
def _assert_conn() -> aiosqlite.Connection: def _assert_conn() -> aiosqlite.Connection:
if _conn is None: if _conn is None:
raise RuntimeError("DB no inicializada — llamar a init_db() antes de usar database.") raise RuntimeError("DB no inicializada — llamar a init_db() antes de usar database.")
@@ -166,23 +162,20 @@ async def crear_invite_code(codigo: str) -> None:
async def verificar_y_consumir_invite_code(codigo: str, chat_id: int) -> bool: async def verificar_y_consumir_invite_code(codigo: str, chat_id: int) -> bool:
""" """
Verifica que el código existe y no fue usado, lo marca como consumido y Verifica y consume un código de invitación en una sola operación atómica.
retorna True. Retorna False si no existe o ya fue usado.
Operación atómica en una sola transacción SQLite. Usa UPDATE con condición AND usado=0 en vez de SELECT+UPDATE para eliminar la
race condition TOCTOU: dos requests concurrentes con el mismo código solo
pueden actualizar la fila una vez — el segundo UPDATE afecta 0 rows y retorna False.
""" """
conn = _assert_conn() conn = _assert_conn()
async with conn.execute( cur = await conn.execute(
"SELECT usado FROM invite_codes WHERE codigo = ?", (codigo,) "UPDATE invite_codes SET usado = 1, usado_por = ?, usado_at = ? "
) as cur: "WHERE codigo = ? AND usado = 0",
row = await cur.fetchone()
if row is None or row["usado"]:
return False
await conn.execute(
"UPDATE invite_codes SET usado = 1, usado_por = ?, usado_at = ? WHERE codigo = ?",
(chat_id, _now_iso(), codigo), (chat_id, _now_iso(), codigo),
) )
await conn.commit() await conn.commit()
if cur.rowcount > 0:
logger.info("invite_code_consumido", chat_id=chat_id) logger.info("invite_code_consumido", chat_id=chat_id)
return True return True
return False

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
pedrito:
build: .
restart: unless-stopped
env_file: .env
ports:
- "8080:8080" # health check (y webhook si está configurado)
volumes:
- ./data:/app/data # snapshot y DB persistentes entre reinicios
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8080/health"]
interval: 60s
timeout: 5s
retries: 3
start_period: 15s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

259
health.py Normal file
View File

@@ -0,0 +1,259 @@
"""
health.py — Servidor HTTP para health check y webhook de Telegram.
Rutas:
GET /health → estado mínimo (público) | estado completo (con Bearer token)
GET /health/history → últimos N eventos del audit_log — REQUIERE Bearer token
POST /webhook → recibe updates de Telegram (solo en modo webhook)
Seguridad:
- /health sin auth: solo devuelve {"status": "ok"|"degraded", "timestamp": "..."}
Sin conteos de tenants ni timing que revele inteligencia operacional.
- /health con auth: respuesta completa con métricas internas.
- /health/history siempre requiere auth.
- chat_ids en el historial se enmascaran como hashes opacos (no reversibles desde HTTP).
- Webhook valida X-Telegram-Bot-Api-Secret-Token con compare_digest.
Configurar en .env:
HEALTH_AUTH_TOKEN=<token largo aleatorio>
(generar con: python -c "import secrets; print(secrets.token_hex(32))")
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import secrets
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
_PY_TZ = ZoneInfo("America/Asuncion")
from typing import TYPE_CHECKING
import aiohttp.web
import structlog
from telegram import Update
if TYPE_CHECKING:
from config import Settings
from telegram.ext import Application
logger = structlog.get_logger(__name__)
# ── Estado global del poller ──────────────────────────────────────────────────
_last_poll_at: datetime | None = None
_last_poll_tenant_count: int = 0
_alert_sent: bool = False
def record_poll(tenant_count: int) -> None:
"""Llamar desde poller.py después de cada ronda automática."""
global _last_poll_at, _last_poll_tenant_count, _alert_sent
_last_poll_at = datetime.now(timezone.utc)
_last_poll_tenant_count = tenant_count
_alert_sent = False
def _mask_chat_id(chat_id: int | None) -> str | None:
"""
Convierte un chat_id en un identificador opaco no reversible.
Preserva la capacidad de correlacionar eventos del mismo usuario
sin exponer el Telegram ID real.
"""
if chat_id is None:
return None
return "usr_" + hashlib.sha256(str(chat_id).encode()).hexdigest()[:8]
# ── Servidor ──────────────────────────────────────────────────────────────────
class HealthServer:
def __init__(self, settings: Settings, application: Application) -> None:
self._settings = settings
self._app_ptb = application
self._runner: aiohttp.web.AppRunner | None = None
self._watcher_task: asyncio.Task | None = None
async def start(self) -> None:
app = aiohttp.web.Application()
app.router.add_get("/health", self._handle_health)
app.router.add_get("/health/history", self._handle_history)
if self._settings.webhook_url:
app.router.add_post("/webhook", self._handle_webhook)
logger.info("webhook_endpoint_activo", url=f"{self._settings.webhook_url}/webhook")
self._runner = aiohttp.web.AppRunner(app)
await self._runner.setup()
site = aiohttp.web.TCPSite(self._runner, "0.0.0.0", self._settings.health_port)
await site.start()
logger.info(
"health_server_iniciado",
port=self._settings.health_port,
auth_configurada=bool(self._settings.health_auth_token),
)
self._watcher_task = asyncio.create_task(self._watcher_loop())
async def stop(self) -> None:
if self._watcher_task:
self._watcher_task.cancel()
try:
await self._watcher_task
except asyncio.CancelledError:
pass
if self._runner:
await self._runner.cleanup()
logger.info("health_server_detenido")
# ── Auth ──────────────────────────────────────────────────────────────────
def _is_authenticated(self, request: aiohttp.web.Request) -> bool:
"""
Valida el header Authorization: Bearer <token>.
Si health_auth_token no está configurado, siempre retorna False
(los endpoints protegidos devuelven respuesta mínima, no error).
"""
if not self._settings.health_auth_token:
return False
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return False
provided = auth[7:]
# compare_digest previene timing attacks sobre el token de admin
return secrets.compare_digest(provided, self._settings.health_auth_token)
# ── Handlers ─────────────────────────────────────────────────────────────
async def _handle_health(self, request: aiohttp.web.Request) -> aiohttp.web.Response:
now = datetime.now(timezone.utc)
stale = False
if _last_poll_at:
stale = (now - _last_poll_at) > timedelta(hours=self._settings.health_alert_hours)
# Respuesta pública mínima — no revela conteos ni timing operacional
data: dict = {
"status": "degraded" if stale else "ok",
"timestamp": now.isoformat(),
}
# Respuesta extendida solo con auth válida
if self._is_authenticated(request):
from database import get_conn
conn = get_conn()
async with conn.execute(
"SELECT COUNT(*) as n FROM tenants WHERE estado = 'activo'"
) as cur:
row = await cur.fetchone()
tenant_count = row["n"] if row else 0
seconds_since_poll: int | None = None
if _last_poll_at:
seconds_since_poll = int((now - _last_poll_at).total_seconds())
data.update({
"tenants_activos": tenant_count,
"last_poll": _last_poll_at.isoformat() if _last_poll_at else None,
"last_poll_tenants": _last_poll_tenant_count,
"seconds_since_last_poll": seconds_since_poll,
"poll_interval_minutes": self._settings.poll_interval_minutes,
"webhook_mode": bool(self._settings.webhook_url),
})
return aiohttp.web.Response(
text=json.dumps(data, indent=2),
content_type="application/json",
status=503 if stale else 200,
)
async def _handle_history(self, request: aiohttp.web.Request) -> aiohttp.web.Response:
# Este endpoint siempre requiere autenticación — contiene PII (chat_ids)
if not self._is_authenticated(request):
logger.warning(
"health_history_acceso_denegado",
ip=request.remote,
ua=request.headers.get("User-Agent", "")[:80],
)
return aiohttp.web.Response(
status=401,
text=json.dumps({"error": "Authentication required"}),
content_type="application/json",
headers={"WWW-Authenticate": 'Bearer realm="pedrito-health"'},
)
from database import get_conn
conn = get_conn()
try:
limit = min(int(request.rel_url.query.get("limit", "50")), 200)
except (ValueError, TypeError):
limit = 50
async with conn.execute(
"SELECT timestamp, timestamp_local, chat_id, evento, resultado, duracion_ms "
"FROM audit_log ORDER BY id DESC LIMIT ?",
(limit,),
) as cur:
rows = await cur.fetchall()
# Enmascarar chat_ids: el consumidor puede correlacionar eventos del mismo usuario
# pero no puede determinar la identidad real sin acceso a la DB.
records = []
for r in rows:
row_dict = dict(r)
row_dict["chat_id"] = _mask_chat_id(row_dict.get("chat_id"))
records.append(row_dict)
return aiohttp.web.Response(
text=json.dumps(records, indent=2),
content_type="application/json",
)
async def _handle_webhook(self, request: aiohttp.web.Request) -> aiohttp.web.Response:
provided = request.headers.get("X-Telegram-Bot-Api-Secret-Token", "")
if self._settings.webhook_secret:
if not secrets.compare_digest(provided, self._settings.webhook_secret):
logger.warning("webhook_token_invalido", ip=request.remote)
# Respuesta genérica — no revelar si es auth vs otro error
return aiohttp.web.Response(status=404, text="Not Found")
try:
body = await request.json()
except Exception:
return aiohttp.web.Response(status=400, text="Bad Request")
update = Update.de_json(data=body, bot=self._app_ptb.bot)
await self._app_ptb.update_queue.put(update)
return aiohttp.web.Response(status=200, text="OK")
# ── Watcher ───────────────────────────────────────────────────────────────
async def _watcher_loop(self) -> None:
"""Alerta al admin si el poller lleva más de health_alert_hours sin actualizar."""
global _alert_sent
while True:
await asyncio.sleep(300)
if _last_poll_at is None or _alert_sent:
continue
ahora_py = datetime.now(_PY_TZ)
en_horario = (
ahora_py.weekday() < 5
and self._settings.poll_hora_inicio <= ahora_py.hour < self._settings.poll_hora_fin
)
if not en_horario:
continue
delta = datetime.now(timezone.utc) - _last_poll_at
if delta <= timedelta(hours=self._settings.health_alert_hours):
continue
minutos = int(delta.total_seconds() // 60)
try:
await self._app_ptb.bot.send_message(
chat_id=self._settings.telegram_chat_id,
text=(
f"⚠️ El poller no actualizó hace {minutos} minutos.\n"
"Puede haber un problema con el bot o la conexión al PJ."
),
)
_alert_sent = True
logger.warning("health_alerta_enviada", minutos=minutos)
except Exception as e:
logger.error("health_alerta_error", error=str(e))

103
main.py
View File

@@ -1,21 +1,25 @@
""" """
main.py — Entrypoint de Pedrito Hechakuaa. main.py — Entrypoint de Pedrito Hechakuaa.
Arranca el bot de Telegram y el poller en paralelo. Arranca el bot de Telegram, el poller y el servidor HTTP de health check en paralelo.
Al arrancar: inicializa la DB y migra el tenant principal del .env si no existe. Al arrancar: aplica migraciones y migra el tenant principal del .env si no existe.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import os
import signal import signal
import sys import sys
import threading
import structlog import structlog
from telegram.ext import Application from telegram.ext import Application
import health as health_mod
import vault as vault_mod import vault as vault_mod
from config import get_settings from config import get_settings
from database import get_tenant, init_db, save_tenant from database import get_tenant, run_migrations, save_tenant
from health import HealthServer, record_poll
from onboarding import OnboardingFlow from onboarding import OnboardingFlow
from poller import Poller from poller import Poller
from telegram_bot import PedritoBot from telegram_bot import PedritoBot
@@ -69,18 +73,68 @@ async def _migrar_tenant_principal(settings) -> None:
logger.info("tenant_principal_migrado", chat_id=chat_id) logger.info("tenant_principal_migrado", chat_id=chat_id)
def _limpiar_env_sensibles() -> None:
"""
Elimina variables sensibles del entorno del proceso después de cargar Settings.
Protege contra:
- cat /proc/<pid>/environ (cualquier usuario con acceso al PID)
- herencia accidental de env vars por subprocesos
- stack traces que vuelquen os.environ
NO protege contra 'docker inspect' (que lee la config del contenedor, no el env live).
Para eso usar Docker secrets con Swarm mode.
"""
_sensibles = (
"FERNET_KEY",
"TELEGRAM_BOT_TOKEN",
"PJ_USUARIO_ENC",
"PJ_PASSWORD_ENC",
"WEBHOOK_SECRET",
"HEALTH_AUTH_TOKEN",
"INVITE_CODE",
"ADMIN_TOKEN",
)
for var in _sensibles:
os.environ.pop(var, None)
def _run_admin(settings) -> None:
"""Arranca el panel de administración en su propio thread y event loop."""
import uvicorn
from admin.app import create_app
if not settings.admin_token:
logger.warning("admin_panel_sin_token_no_iniciado")
return
admin_app = create_app(
db_path=str(settings.db_path),
admin_token=settings.admin_token,
data_dir=settings.data_dir,
)
uvicorn.run(
admin_app,
host="127.0.0.1",
port=settings.admin_port,
log_level="warning",
)
async def main() -> None: async def main() -> None:
settings = get_settings() settings = get_settings()
_limpiar_env_sensibles()
logger.info( logger.info(
"pedrito_hechakuaa_iniciando", "pedrito_hechakuaa_iniciando",
poll_interval_minutes=settings.poll_interval_minutes, poll_interval_minutes=settings.poll_interval_minutes,
horario=f"{settings.poll_hora_inicio}:00-{settings.poll_hora_fin}:00", horario=f"{settings.poll_hora_inicio}:00-{settings.poll_hora_fin}:00",
chat_id=settings.telegram_chat_id, chat_id=settings.telegram_chat_id,
webhook_mode=bool(settings.webhook_url),
) )
# Base de datos # Migraciones y tenant principal
await init_db(settings.db_path) await run_migrations(settings.db_path)
await _migrar_tenant_principal(settings) await _migrar_tenant_principal(settings)
# Bot de Telegram # Bot de Telegram
@@ -92,19 +146,24 @@ async def main() -> None:
) )
app: Application = pedrito_bot.build() app: Application = pedrito_bot.build()
# Poller (sin snapshot global — maneja uno por tenant internamente) # Poller
poller = Poller( poller = Poller(
settings=settings, settings=settings,
bot=pedrito_bot, bot=pedrito_bot,
on_poll_done=record_poll,
) )
# El callback de /notif pasa el chat_id al poller
pedrito_bot.set_notif_callback( pedrito_bot.set_notif_callback(
lambda chat_id: poller.revisar_ahora(chat_id=chat_id, es_manual=True) lambda chat_id: poller.revisar_ahora(chat_id=chat_id, es_manual=True)
) )
# Panel de administración (thread daemon — muere con el proceso principal)
admin_thread = threading.Thread(target=_run_admin, args=(settings,), daemon=True)
admin_thread.start()
# Manejo de señales para shutdown limpio # Manejo de señales para shutdown limpio
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
health_server: HealthServer | None = None
def handle_shutdown(sig: int) -> None: def handle_shutdown(sig: int) -> None:
logger.info("shutdown_recibido", signal=sig) logger.info("shutdown_recibido", signal=sig)
@@ -117,25 +176,51 @@ async def main() -> None:
except NotImplementedError: except NotImplementedError:
pass # Windows no soporta add_signal_handler pass # Windows no soporta add_signal_handler
# Arrancar bot y poller en paralelo
async with app: async with app:
await app.initialize() await app.initialize()
await app.start() await app.start()
logger.info("bot_telegram_iniciado") logger.info("bot_telegram_iniciado")
# Servidor HTTP de health check (y webhook si está configurado)
health_server = HealthServer(settings=settings, application=app)
await health_server.start()
if settings.webhook_url:
# Registrar el webhook con Telegram
webhook_url = f"{settings.webhook_url.rstrip('/')}/webhook"
await app.bot.set_webhook(
url=webhook_url,
secret_token=settings.webhook_secret or None,
drop_pending_updates=True,
)
logger.info("webhook_registrado", url=webhook_url)
telegram_task = asyncio.create_task(asyncio.sleep(0)) # placeholder
else:
# Long-polling
telegram_task = asyncio.create_task( telegram_task = asyncio.create_task(
app.updater.start_polling(drop_pending_updates=True) app.updater.start_polling(drop_pending_updates=True)
) )
poller_task = asyncio.create_task(poller.run_forever()) poller_task = asyncio.create_task(poller.run_forever())
logger.info("pedrito_hechakuaa_corriendo") logger.info("pedrito_hechakuaa_corriendo")
try: try:
if settings.webhook_url:
await poller_task
else:
await asyncio.gather(telegram_task, poller_task) await asyncio.gather(telegram_task, poller_task)
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
finally: finally:
if health_server:
await health_server.stop()
if settings.webhook_url:
try:
await app.bot.delete_webhook()
except Exception:
pass
else:
await app.updater.stop() await app.updater.stop()
await app.stop() await app.stop()

View File

@@ -0,0 +1,42 @@
-- 001_initial_schema.sql — Schema base de Pedrito Hechakuaa
-- Idempotente: usa CREATE TABLE/INDEX IF NOT EXISTS
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS invite_codes (
codigo TEXT PRIMARY KEY,
creado_at TEXT NOT NULL,
usado INTEGER DEFAULT 0,
usado_por INTEGER,
usado_at TEXT
);
CREATE TABLE IF NOT EXISTS tenants (
chat_id INTEGER PRIMARY KEY,
nombre_preferido TEXT,
tono TEXT DEFAULT 'cotidiano',
timezone TEXT DEFAULT 'America/Asuncion',
credentials_enc TEXT,
estado TEXT DEFAULT 'pendiente',
consentimiento_aceptado INTEGER DEFAULT 0,
created_at TEXT,
last_active TEXT
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
timestamp_local TEXT,
chat_id INTEGER,
evento TEXT NOT NULL,
detalle TEXT,
resultado TEXT,
duracion_ms INTEGER
);
CREATE INDEX IF NOT EXISTS idx_audit_chat_id ON audit_log(chat_id);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_evento ON audit_log(evento);

View File

@@ -16,6 +16,9 @@ Estados:
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import secrets
import time
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -201,13 +204,28 @@ class OnboardingFlow:
async def _paso_codigo(self, chat_id: int, codigo: str, update: Update) -> None: async def _paso_codigo(self, chat_id: int, codigo: str, update: Update) -> None:
codigo = codigo.strip() codigo = codigo.strip()
# Registrar tiempo de inicio para garantizar respuesta de duración constante.
# Objetivo: ocultar la diferencia de tiempo entre "código no existe en DB"
# (~0.001ms) y "código existe pero ya fue usado" (~3-5ms de DB lookup).
t0 = time.monotonic()
# Primero: verificar contra la tabla de códigos de un solo uso (DB) # Primero: verificar contra la tabla de códigos de un solo uso (DB)
valido = await verificar_y_consumir_invite_code(codigo, chat_id) valido = await verificar_y_consumir_invite_code(codigo, chat_id)
# Fallback: código global del .env (ilimitado, para uso interno/pruebas) # Fallback: código global del .env (ilimitado, para uso interno/pruebas)
# secrets.compare_digest previene timing attacks sobre el valor del .env
if not valido and self._settings.invite_code: if not valido and self._settings.invite_code:
valido = (codigo == self._settings.invite_code) valido = secrets.compare_digest(
codigo.encode("utf-8"),
self._settings.invite_code.encode("utf-8"),
)
# Garantizar un tiempo mínimo de 800ms independientemente del resultado.
# Esto hace que medir tiempos de respuesta no revele información sobre los códigos.
elapsed = time.monotonic() - t0
_MIN_RESP_SECS = 0.8
if elapsed < _MIN_RESP_SECS:
await asyncio.sleep(_MIN_RESP_SECS - elapsed)
if valido: if valido:
await audit.log("onboarding_codigo_correcto", chat_id=chat_id) await audit.log("onboarding_codigo_correcto", chat_id=chat_id)

43
pdf_extractor.py Normal file
View File

@@ -0,0 +1,43 @@
"""
pdf_extractor.py — Extracción de texto de PDFs judiciales.
Sin LLM. Sin interpretación. Solo texto crudo.
Los PDFs nunca se guardan en disco — se procesan en memoria.
"""
from __future__ import annotations
import io
import re
from pypdf import PdfReader
MAX_CHARS = 600
def extraer_extracto(pdf_bytes: bytes) -> str | None:
"""
Extrae las primeras líneas de texto de un PDF judicial.
Retorna el extracto como string, o None si el PDF no tiene texto
extraíble (PDF escaneado, imagen, error de parseo).
"""
try:
reader = PdfReader(io.BytesIO(pdf_bytes))
texto = ""
for page in reader.pages:
texto += page.extract_text() or ""
if len(texto) >= MAX_CHARS:
break
texto = texto.strip()
if not texto:
return None
texto = re.sub(r"\n{3,}", "\n\n", texto)
if len(texto) > MAX_CHARS:
texto = texto[:MAX_CHARS] + "..."
return texto
except Exception:
return None

View File

@@ -9,6 +9,7 @@ Responsabilidades:
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -22,8 +23,10 @@ from csj_client import (
CSJAuthError, CSJAuthError,
CSJClient, CSJClient,
CSJConnectionError, CSJConnectionError,
CSJDocumentoNoDisponibleError,
CSJPasswordTemporalError, CSJPasswordTemporalError,
) )
from pdf_extractor import extraer_extracto
from database import delete_tenant, get_tenant, list_active_tenants, save_tenant from database import delete_tenant, get_tenant, list_active_tenants, save_tenant
from storage import Snapshot from storage import Snapshot
@@ -42,9 +45,11 @@ class Poller:
self, self,
settings: Settings, settings: Settings,
bot: object, # PedritoBot — evitamos import circular bot: object, # PedritoBot — evitamos import circular
on_poll_done: Callable[[int], None] | None = None,
) -> None: ) -> None:
self._settings = settings self._settings = settings
self._bot = bot self._bot = bot
self._on_poll_done = on_poll_done
self._running = False self._running = False
self._snapshots: dict[int, Snapshot] = {} self._snapshots: dict[int, Snapshot] = {}
self._errores_consecutivos: dict[int, int] = {} self._errores_consecutivos: dict[int, int] = {}
@@ -63,6 +68,29 @@ class Poller:
self._snapshots[chat_id] = Snapshot(path) self._snapshots[chat_id] = Snapshot(path)
return self._snapshots[chat_id] return self._snapshots[chat_id]
async def _intentar_extracto(
self,
client: CSJClient,
notif: object,
) -> str | None:
"""Descarga el PDF de la notificación y extrae el texto. Degradación elegante: None si falla."""
from csj_client import NotificacionPendiente # evita import circular en type hint
assert isinstance(notif, NotificacionPendiente)
if not (notif.cod_caso_judicial and notif.cod_actuacion_caso and notif.origen is not None):
return None
try:
pdf_bytes = await client.descargar_documento_principal(
cod_caso_judicial=notif.cod_caso_judicial,
origen_caso=notif.origen,
cod_actuacion_caso=notif.cod_actuacion_caso,
origen_actuacion=notif.origen,
)
return extraer_extracto(pdf_bytes)
except (CSJDocumentoNoDisponibleError, CSJConnectionError, CSJAuthError):
return None
except Exception:
return None
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
# Lógica de revisión por tenant # Lógica de revisión por tenant
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
@@ -114,7 +142,8 @@ class Poller:
if nuevas: if nuevas:
for notif in nuevas: for notif in nuevas:
await self._bot.enviar_notificacion(notif, tenant) # type: ignore[attr-defined] extracto = await self._intentar_extracto(client, notif)
await self._bot.enviar_notificacion(notif, tenant, extracto=extracto) # type: ignore[attr-defined]
snapshot.marcar_vista(notif.cod_notificacion_origen) snapshot.marcar_vista(notif.cod_notificacion_origen)
elif es_manual: elif es_manual:
await self._bot.enviar_sin_novedades(tenant, snapshot) # type: ignore[attr-defined] await self._bot.enviar_sin_novedades(tenant, snapshot) # type: ignore[attr-defined]
@@ -163,23 +192,27 @@ class Poller:
# API pública # API pública
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
async def revisar_ahora(self, chat_id: int | None = None, es_manual: bool = False) -> None: async def revisar_ahora(self, chat_id: int | None = None, es_manual: bool = False) -> int:
""" """
Si chat_id: revisar solo ese tenant (para /notif manual). Si chat_id: revisar solo ese tenant (para /notif manual).
Si None: revisar todos los tenants activos. Si None: revisar todos los tenants activos.
Retorna la cantidad de tenants revisados.
""" """
if chat_id is not None: if chat_id is not None:
tenant = await get_tenant(chat_id) tenant = await get_tenant(chat_id)
if tenant and tenant.get("estado") == "activo": if tenant and tenant.get("estado") == "activo":
await self._revisar_tenant(tenant, es_manual=es_manual) await self._revisar_tenant(tenant, es_manual=es_manual)
return 1
elif es_manual and tenant: elif es_manual and tenant:
logger.warning("revisar_ahora_tenant_no_activo", chat_id=chat_id, estado=tenant.get("estado")) logger.warning("revisar_ahora_tenant_no_activo", chat_id=chat_id, estado=tenant.get("estado"))
return 0
else: else:
tenants = await list_active_tenants() tenants = await list_active_tenants()
if not tenants: if not tenants:
logger.info("sin_tenants_activos_skip") logger.info("sin_tenants_activos_skip")
return return 0
await asyncio.gather(*(self._revisar_tenant(t) for t in tenants)) await asyncio.gather(*(self._revisar_tenant(t) for t in tenants))
return len(tenants)
def disparar_revision_manual(self, chat_id: int) -> None: def disparar_revision_manual(self, chat_id: int) -> None:
"""Llamado desde el handler de /notif para disparar revisión inmediata.""" """Llamado desde el handler de /notif para disparar revisión inmediata."""
@@ -198,8 +231,13 @@ class Poller:
hora_fin=self._settings.poll_hora_fin, hora_fin=self._settings.poll_hora_fin,
) )
if self._en_horario_habitual():
logger.info("revision_inicial_al_arrancar") logger.info("revision_inicial_al_arrancar")
await self.revisar_ahora() count = await self.revisar_ahora()
if self._on_poll_done:
self._on_poll_done(count)
else:
logger.info("revision_inicial_skipped_fuera_de_horario")
while self._running: while self._running:
try: try:
@@ -211,6 +249,8 @@ class Poller:
except asyncio.TimeoutError: except asyncio.TimeoutError:
if self._en_horario_habitual(): if self._en_horario_habitual():
await self.revisar_ahora() count = await self.revisar_ahora()
if self._on_poll_done:
self._on_poll_done(count)
else: else:
logger.debug("fuera_de_horario_skip") logger.debug("fuera_de_horario_skip")

BIN
pruebas_beta_pedrito.docx Normal file

Binary file not shown.

View File

@@ -14,6 +14,11 @@ dependencies = [
"structlog>=24.4", "structlog>=24.4",
"python-dotenv>=1.0", "python-dotenv>=1.0",
"aiosqlite>=0.20", "aiosqlite>=0.20",
"aiohttp>=3.9",
"pypdf>=4.0",
"fastapi>=0.115",
"uvicorn>=0.32",
"jinja2>=3.1",
] ]
[dependency-groups] [dependency-groups]

163
scripts/migrar_vault_v3.py Normal file
View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""
scripts/migrar_vault_v3.py — Migración de credenciales v1 → v3 (PBKDF2 + envelope encryption).
Qué hace:
1. Lee FERNET_KEY del .env
2. Conecta a la DB y lista todos los tenants con credentials_enc
3. Saltea tenants ya en v3 (JSON, empieza con '{')
4. Para cada tenant v1: descifra con el algoritmo legacy, re-cifra con v3
5. Si algún tenant falla al descifrar: aborta SIN escribir nada
6. Si todo OK: hace backup de la DB, aplica todos los UPDATE en una transacción atómica
Uso:
python scripts/migrar_vault_v3.py # migra de verdad
python scripts/migrar_vault_v3.py --dry-run # solo muestra qué haría, no escribe
Seguridad:
- Las credenciales descifradas NUNCA se escriben en disco ni en logs
- El backup de la DB se hace ANTES de cualquier modificación
- Si el proceso se interrumpe a mitad, el backup permite restaurar
"""
from __future__ import annotations
import argparse
import asyncio
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
async def main() -> None:
parser = argparse.ArgumentParser(
description="Migra credenciales de vault v1 a v3 (PBKDF2 + envelope encryption)"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Muestra qué haría sin escribir nada en la DB",
)
args = parser.parse_args()
env_path = Path(".env")
if not env_path.exists():
print("ERROR: No encontré .env. Ejecutá desde el directorio raíz del proyecto.")
sys.exit(1)
# Leer FERNET_KEY del .env
import re
env_contenido = env_path.read_text(encoding="utf-8")
m = re.search(r"^FERNET_KEY=(.+)$", env_contenido, re.MULTILINE)
if not m or not m.group(1).strip():
print("ERROR: FERNET_KEY no encontrada o vacía en .env")
sys.exit(1)
fernet_key = m.group(1).strip()
from config import get_settings
from database import run_migrations, get_conn
import vault
settings = get_settings()
await run_migrations(settings.db_path)
conn = get_conn()
# Traer todos los tenants con credentials_enc
async with conn.execute(
"SELECT chat_id, credentials_enc FROM tenants WHERE credentials_enc IS NOT NULL"
) as cur:
tenants = list(await cur.fetchall())
if not tenants:
print("No hay tenants con credenciales en la DB. Nada que migrar.")
return
total = len(tenants)
ya_v3 = 0
a_migrar: list[tuple[int, str]] = [] # (chat_id, credentials_enc_v1)
for row in tenants:
if vault.es_formato_v3(row["credentials_enc"]):
ya_v3 += 1
else:
a_migrar.append((row["chat_id"], row["credentials_enc"]))
print()
print("=== Migración vault v1 → v3 ===")
print()
print(f" Tenants totales: {total}")
print(f" Ya en v3: {ya_v3} (se saltean)")
print(f" A migrar (v1): {len(a_migrar)}")
print()
if not a_migrar:
print(" Todos los tenants ya están en v3. Nada que hacer.")
return
if args.dry_run:
print(" [DRY RUN] Los siguientes tenants serían migrados:")
for chat_id, _ in a_migrar:
print(f" chat_id={chat_id}")
print()
print(" [DRY RUN] No se escribió nada.")
return
# Descifrar todos ANTES de tocar la DB — abortar si alguno falla
actualizaciones: list[tuple[str, int]] = [] # (nuevo_enc, chat_id)
errores: list[tuple[int, str]] = []
print(" Verificando que todos los v1 sean descifrables...")
for chat_id, credentials_enc in a_migrar:
try:
usuario, password = vault.descifrar_credenciales(chat_id, credentials_enc, fernet_key)
nuevo_enc = vault.cifrar_credenciales(chat_id, usuario, password, fernet_key)
actualizaciones.append((nuevo_enc, chat_id))
del usuario, password # no dejar en memoria más de lo necesario
except ValueError as e:
errores.append((chat_id, str(e)))
print(f" ERROR tenant {chat_id}: {e}")
if errores:
print()
print(f" {len(errores)} tenant(s) no pudieron descifrarse.")
print(" La DB NO fue modificada. Revisá los errores de arriba.")
sys.exit(1)
print(f" OK — {len(actualizaciones)} tenant(s) listos para migrar.")
print()
# Confirmar
confirmar = input(" Escribí 'migrar' para confirmar y escribir en la DB: ").strip()
if confirmar != "migrar":
print(" Operación cancelada.")
sys.exit(0)
# Backup de la DB ANTES de cualquier escritura
db_path = settings.db_path
bak_path = db_path.with_suffix(".db.bak")
shutil.copy2(db_path, bak_path)
print(f"\n Backup guardado en {bak_path}")
# Aplicar todos los UPDATE en una sola transacción
for nuevo_enc, chat_id in actualizaciones:
await conn.execute(
"UPDATE tenants SET credentials_enc = ? WHERE chat_id = ?",
(nuevo_enc, chat_id),
)
await conn.commit()
print(f" {len(actualizaciones)} tenant(s) migrados a v3.")
print()
print(" ✅ Migración completada.")
print(f" Backup de la DB anterior: {bak_path}")
print()
print(" Próximo paso: reiniciar el bot.")
print(" systemctl restart pedrito (en VPS con systemd)")
print(" docker compose restart (en VPS con Docker)")
print(" o Ctrl+C y uv run python main.py (en local)")
print()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -10,6 +10,7 @@ Responsabilidades:
from __future__ import annotations from __future__ import annotations
import re import re
import unicodedata
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -38,6 +39,7 @@ from csj_client import (
) )
from database import get_tenant from database import get_tenant
from onboarding import OnboardingFlow from onboarding import OnboardingFlow
from pdf_extractor import extraer_extracto
from storage import Snapshot from storage import Snapshot
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -47,6 +49,41 @@ logger = structlog.get_logger(__name__)
PJ_URL = "https://apps.csj.gov.py/gestion-partes/notificaciones-electronicas" PJ_URL = "https://apps.csj.gov.py/gestion-partes/notificaciones-electronicas"
# ──────────────────────────────────────────────────────────────────────────────
# Mapeo de frases libres a comandos (sin LLM)
# ──────────────────────────────────────────────────────────────────────────────
_FRASES_COMANDOS: dict[str, str] = {
"novedades": "notif",
"actualizaciones": "notif",
"que hay nuevo": "notif",
"qué hay nuevo": "notif",
"hay algo nuevo": "notif",
"alguna novedad": "notif",
"notificaciones": "notif",
"notificacion": "notif",
"notificación": "notif",
"revisar": "notif",
"actualizar": "notif",
"estado": "estado",
"como estas": "estado",
"cómo estás": "estado",
"ayuda": "ayuda",
"help": "ayuda",
"que podes hacer": "ayuda",
"qué podés hacer": "ayuda",
"comandos": "ayuda",
}
def _normalizar(texto: str) -> str:
"""Lowercase, sin acentos, sin puntuación — para comparación de frases."""
texto = texto.lower().strip()
texto = unicodedata.normalize("NFD", texto)
texto = "".join(c for c in texto if unicodedata.category(c) != "Mn")
texto = re.sub(r"[^\w\s]", " ", texto)
return re.sub(r"\s+", " ", texto).strip()
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# Helpers de formato # Helpers de formato
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
@@ -258,8 +295,10 @@ class PedritoBot:
return None return None
async def _rechazar_no_registrado(self, update: Update) -> None: async def _rechazar_no_registrado(self, update: Update) -> None:
# Mensaje mínimo: no revela qué hace el bot ni su arquitectura interna.
# Solo dice que hay un comando /start, lo cual es estándar en todos los bots de Telegram.
await update.effective_message.reply_text( # type: ignore[union-attr] await update.effective_message.reply_text( # type: ignore[union-attr]
"Primero tenés que registrarte\\. Escribí /start\\.", "Para usar este bot escribí /start\\.",
parse_mode=ParseMode.MARKDOWN_V2, parse_mode=ParseMode.MARKDOWN_V2,
) )
@@ -609,6 +648,13 @@ class PedritoBot:
filename=filename, filename=filename,
caption=caption, caption=caption,
) )
extracto = extraer_extracto(pdf_bytes)
if extracto:
await update.effective_message.reply_text( # type: ignore[union-attr]
f"📄 *Contenido del documento:*\n{_escape_md(extracto)}\n\n"
"⚠️ _Verificá el original antes de actuar\\._",
parse_mode=ParseMode.MARKDOWN_V2,
)
await audit.log("bot_pdf_enviado", chat_id=chat_id, detalle={"cod_caso": caso.cod_caso_judicial, "cod_actuacion": actuacion.cod_actuacion_caso}, tenant=tenant) await audit.log("bot_pdf_enviado", chat_id=chat_id, detalle={"cod_caso": caso.cod_caso_judicial, "cod_actuacion": actuacion.cod_actuacion_caso}, tenant=tenant)
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
@@ -624,6 +670,45 @@ class PedritoBot:
if self._onboarding.is_in_onboarding(chat_id): if self._onboarding.is_in_onboarding(chat_id):
await self._onboarding.handle_mensaje(update, context) await self._onboarding.handle_mensaje(update, context)
async def _intentar_mapear_comando(
self,
update: Update,
context: ContextTypes.DEFAULT_TYPE,
texto: str,
) -> bool:
"""
Intenta mapear texto libre a un comando conocido sin LLM.
Retorna True si encontró match y ejecutó el handler correspondiente.
"""
texto_norm = _normalizar(texto)
texto_lower = texto.lower()
# Regex especiales sobre texto original (preserva la "/")
m_exp = re.search(r"\bexp(?:ediente)?\b\s+(\d+(?:/\d+)?)", texto_lower)
if m_exp:
context.args = [m_exp.group(1)] # type: ignore[assignment]
await self.cmd_exp(update, context)
return True
m_pdf = re.search(r"\bpdf\b\s+(\d+/\d+)", texto_lower)
if m_pdf:
context.args = [m_pdf.group(1)] # type: ignore[assignment]
await self.cmd_pdf(update, context)
return True
# Matching por frases del diccionario
for frase, comando in _FRASES_COMANDOS.items():
if _normalizar(frase) in texto_norm:
if comando == "notif":
await self.cmd_notif(update, context)
elif comando == "estado":
await self.cmd_estado(update, context)
elif comando == "ayuda":
await self.cmd_ayuda(update, context)
return True
return False
async def handle_texto_libre(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def handle_texto_libre(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Responde a texto que no es comando ni está en un flujo de onboarding.""" """Responde a texto que no es comando ni está en un flujo de onboarding."""
chat_id = update.effective_chat.id # type: ignore[union-attr] chat_id = update.effective_chat.id # type: ignore[union-attr]
@@ -633,11 +718,15 @@ class PedritoBot:
return # el onboarding lo maneja en handle_mensaje, no responder acá return # el onboarding lo maneja en handle_mensaje, no responder acá
tenant = await get_tenant(chat_id) tenant = await get_tenant(chat_id)
if tenant and tenant.get("estado") == "activo": if not (tenant and tenant.get("estado") == "activo"):
texto = _escape_md(_texto_tono("texto_no_reconocido", tenant)) # Usuarios sin registro: silencio total.
else: return
texto = _escape_md("Solo respondo comandos específicos. Escribí /ayuda para ver la lista.")
texto_original = (update.effective_message.text or "") # type: ignore[union-attr]
if await self._intentar_mapear_comando(update, context, texto_original):
return
texto = _escape_md(_texto_tono("texto_no_reconocido", tenant))
await update.effective_message.reply_text(texto, parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr] await update.effective_message.reply_text(texto, parse_mode=ParseMode.MARKDOWN_V2) # type: ignore[union-attr]
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
@@ -701,13 +790,25 @@ class PedritoBot:
document=pdf_bytes, document=pdf_bytes,
filename=f"Actuacion_{cod_caso}_{cod_actuacion}.pdf", filename=f"Actuacion_{cod_caso}_{cod_actuacion}.pdf",
) )
extracto = extraer_extracto(pdf_bytes)
if extracto:
await query.message.reply_text( # type: ignore[union-attr]
f"📄 *Contenido del documento:*\n{_escape_md(extracto)}\n\n"
"⚠️ _Verificá el original antes de actuar\\._",
parse_mode=ParseMode.MARKDOWN_V2,
)
await audit.log("bot_pdf_enviado", chat_id=chat_id, detalle={"cod_caso": int(cod_caso), "cod_actuacion": int(cod_actuacion)}, tenant=await get_tenant(chat_id)) await audit.log("bot_pdf_enviado", chat_id=chat_id, detalle={"cod_caso": int(cod_caso), "cod_actuacion": int(cod_actuacion)}, tenant=await get_tenant(chat_id))
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
# Mensajes proactivos (llamados desde el poller) # Mensajes proactivos (llamados desde el poller)
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
async def enviar_notificacion(self, notif: NotificacionPendiente, tenant: dict) -> None: async def enviar_notificacion(
self,
notif: NotificacionPendiente,
tenant: dict,
extracto: str | None = None,
) -> None:
"""Envía una notificación nueva al tenant específico, con su tono.""" """Envía una notificación nueva al tenant específico, con su tono."""
assert self._app is not None assert self._app is not None
chat_id: int = tenant["chat_id"] chat_id: int = tenant["chat_id"]
@@ -723,13 +824,22 @@ class PedritoBot:
nombre=nombre, exp=notif.expediente_str nombre=nombre, exp=notif.expediente_str
) )
extracto_bloque = ""
if extracto:
extracto_bloque = (
f"\n\n📄 *Extracto del documento:*\n"
f"{_escape_md(extracto)}\n\n"
f"⚠️ _Este es el texto crudo del documento\\. Verificá el original antes de actuar\\._"
)
texto = ( texto = (
f"{_escape_md(intro)}\n\n" f"{_escape_md(intro)}\n\n"
f"📋 *Nueva notificación judicial*\n\n" f"📋 *Nueva notificación judicial*\n\n"
f"{_escape_md(caratula)}\n" f"{_escape_md(caratula)}\n"
f"Exp\\. {_escape_md(notif.expediente_str)} \\{_escape_md(juzgado)}\n\n" f"Exp\\. {_escape_md(notif.expediente_str)} \\{_escape_md(juzgado)}\n\n"
f"Tipo: {_escape_md(tipo)}\n" f"Tipo: {_escape_md(tipo)}\n"
f"Fecha: {_escape_md(fecha)}\n\n" f"Fecha: {_escape_md(fecha)}"
f"{extracto_bloque}\n\n"
f"⚠️ Para acusar esta notificación entrá directamente en el sistema del PJ\\." f"⚠️ Para acusar esta notificación entrá directamente en el sistema del PJ\\."
) )

637
uv.lock generated
View File

@@ -7,6 +7,100 @@ resolution-markers = [
"python_full_version < '3.14'", "python_full_version < '3.14'",
] ]
[[package]]
name = "aiohappyeyeballs"
version = "2.6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" },
]
[[package]]
name = "aiohttp"
version = "3.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
{ name = "aiosignal" },
{ name = "attrs" },
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" },
{ url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" },
{ url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" },
{ url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" },
{ url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" },
{ url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" },
{ url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" },
{ url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" },
{ url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" },
{ url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" },
{ url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" },
{ url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" },
{ url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" },
{ url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" },
{ url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" },
{ url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" },
{ url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" },
{ url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" },
{ url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" },
{ url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" },
{ url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" },
{ url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" },
{ url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" },
{ url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" },
{ url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" },
{ url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" },
{ url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" },
{ url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" },
{ url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" },
{ url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" },
{ url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" },
{ url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" },
{ url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" },
{ url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" },
{ url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" },
{ url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" },
{ url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" },
{ url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" },
{ url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" },
{ url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" },
{ url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" },
{ url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" },
{ url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" },
{ url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" },
{ url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" },
{ url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" },
{ url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" },
{ url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" },
{ url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" },
{ url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" },
{ url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" },
{ url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" },
{ url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" },
{ url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" },
{ url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" },
{ url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
]
[[package]] [[package]]
name = "aiolimiter" name = "aiolimiter"
version = "1.2.1" version = "1.2.1"
@@ -16,6 +110,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/ba/df6e8e1045aebc4778d19b8a3a9bc1808adb1619ba94ca354d9ba17d86c3/aiolimiter-1.2.1-py3-none-any.whl", hash = "sha256:d3f249e9059a20badcb56b61601a83556133655c11d1eb3dd3e04ff069e5f3c7", size = 6711, upload-time = "2024-12-08T15:31:49.874Z" }, { url = "https://files.pythonhosted.org/packages/f3/ba/df6e8e1045aebc4778d19b8a3a9bc1808adb1619ba94ca354d9ba17d86c3/aiolimiter-1.2.1-py3-none-any.whl", hash = "sha256:d3f249e9059a20badcb56b61601a83556133655c11d1eb3dd3e04ff069e5f3c7", size = 6711, upload-time = "2024-12-08T15:31:49.874Z" },
] ]
[[package]]
name = "aiosignal"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "frozenlist" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]] [[package]]
name = "aiosqlite" name = "aiosqlite"
version = "0.22.1" version = "0.22.1"
@@ -25,6 +132,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
] ]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]] [[package]]
name = "annotated-types" name = "annotated-types"
version = "0.7.0" version = "0.7.0"
@@ -99,6 +215,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" },
] ]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]] [[package]]
name = "cachetools" name = "cachetools"
version = "7.1.3" version = "7.1.3"
@@ -174,6 +299,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
] ]
[[package]]
name = "click"
version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
]
[[package]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.6" version = "0.4.6"
@@ -236,6 +373,111 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
] ]
[[package]]
name = "fastapi"
version = "0.136.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
]
[[package]]
name = "frozenlist"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
{ url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
{ url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
{ url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
{ url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
{ url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
{ url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
{ url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
{ url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
{ url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
{ url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
{ url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
{ url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
{ url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
{ url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
{ url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
{ url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
{ url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
{ url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
{ url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
{ url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
{ url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
{ url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
{ url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
{ url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
{ url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
{ url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
{ url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
{ url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
{ url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
{ url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
{ url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
{ url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
{ url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
{ url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
{ url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
{ url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
{ url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
{ url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
{ url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
{ url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
{ url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
{ url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
{ url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
{ url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
{ url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
{ url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
{ url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
{ url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
{ url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
{ url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
{ url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
{ url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
{ url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
{ url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
{ url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
{ url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
{ url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
[[package]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" version = "0.16.0"
@@ -291,6 +533,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
] ]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]] [[package]]
name = "librt" name = "librt"
version = "0.11.0" version = "0.11.0"
@@ -351,6 +605,168 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
] ]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "multidict"
version = "6.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" },
{ url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" },
{ url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" },
{ url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" },
{ url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" },
{ url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" },
{ url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" },
{ url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" },
{ url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" },
{ url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" },
{ url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" },
{ url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" },
{ url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" },
{ url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" },
{ url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },
{ url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },
{ url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },
{ url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },
{ url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
{ url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
{ url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" },
{ url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" },
{ url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" },
{ url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" },
{ url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" },
{ url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" },
{ url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" },
{ url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" },
{ url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" },
{ url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" },
{ url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" },
{ url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },
{ url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },
{ url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },
{ url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" },
{ url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" },
{ url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" },
{ url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" },
{ url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" },
{ url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" },
{ url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" },
{ url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" },
{ url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" },
{ url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" },
{ url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" },
{ url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" },
{ url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" },
{ url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" },
{ url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" },
{ url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },
{ url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },
{ url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
{ url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
{ url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
{ url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
{ url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
{ url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
{ url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
{ url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
{ url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
{ url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
{ url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
{ url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
{ url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
{ url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
{ url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
{ url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
{ url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
{ url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
{ url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
{ url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
{ url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
{ url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
{ url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
{ url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
{ url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
{ url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
{ url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]
[[package]] [[package]]
name = "mypy" name = "mypy"
version = "2.1.0" version = "2.1.0"
@@ -427,15 +843,20 @@ name = "pedrito-hechakuaa"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" },
{ name = "aiosqlite" }, { name = "aiosqlite" },
{ name = "apscheduler" }, { name = "apscheduler" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "jinja2" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pypdf" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "python-telegram-bot", extra = ["ext"] }, { name = "python-telegram-bot", extra = ["ext"] },
{ name = "structlog" }, { name = "structlog" },
{ name = "uvicorn" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -449,15 +870,20 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiohttp", specifier = ">=3.9" },
{ name = "aiosqlite", specifier = ">=0.20" }, { name = "aiosqlite", specifier = ">=0.20" },
{ name = "apscheduler", specifier = ">=3.10" }, { name = "apscheduler", specifier = ">=3.10" },
{ name = "cryptography", specifier = ">=44.0" }, { name = "cryptography", specifier = ">=44.0" },
{ name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.28" }, { name = "httpx", specifier = ">=0.28" },
{ name = "jinja2", specifier = ">=3.1" },
{ name = "pydantic", specifier = ">=2.9" }, { name = "pydantic", specifier = ">=2.9" },
{ name = "pydantic-settings", specifier = ">=2.6" }, { name = "pydantic-settings", specifier = ">=2.6" },
{ name = "pypdf", specifier = ">=4.0" },
{ name = "python-dotenv", specifier = ">=1.0" }, { name = "python-dotenv", specifier = ">=1.0" },
{ name = "python-telegram-bot", extras = ["ext"], specifier = ">=21.9" }, { name = "python-telegram-bot", extras = ["ext"], specifier = ">=21.9" },
{ name = "structlog", specifier = ">=24.4" }, { name = "structlog", specifier = ">=24.4" },
{ name = "uvicorn", specifier = ">=0.32" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@@ -478,6 +904,100 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]]
name = "propcache"
version = "0.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" },
{ url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" },
{ url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" },
{ url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" },
{ url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" },
{ url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" },
{ url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" },
{ url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" },
{ url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" },
{ url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" },
{ url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" },
{ url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" },
{ url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" },
{ url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" },
{ url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" },
{ url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" },
{ url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" },
{ url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" },
{ url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" },
{ url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" },
{ url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" },
{ url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" },
{ url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" },
{ url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" },
{ url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" },
{ url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" },
{ url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" },
{ url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" },
{ url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" },
{ url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" },
{ url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" },
{ url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" },
{ url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" },
{ url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" },
{ url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" },
{ url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" },
{ url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" },
{ url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" },
{ url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" },
{ url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" },
{ url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" },
{ url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" },
{ url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" },
{ url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" },
{ url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" },
{ url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" },
{ url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" },
{ url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" },
{ url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" },
{ url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" },
{ url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" },
{ url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" },
{ url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" },
{ url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" },
{ url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" },
{ url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" },
{ url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" },
{ url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" },
{ url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" },
{ url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" },
{ url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" },
{ url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" },
{ url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" },
{ url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" },
{ url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" },
{ url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" },
{ url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" },
{ url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" },
{ url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" },
{ url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" },
{ url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" },
{ url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" },
{ url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
]
[[package]] [[package]]
name = "pycparser" name = "pycparser"
version = "3.0" version = "3.0"
@@ -600,6 +1120,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
] ]
[[package]]
name = "pypdf"
version = "6.12.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0a/6d/20879428577c1e57ecd41b69dc86beabf43db9287ad2e702207f8b48c751/pypdf-6.12.2.tar.gz", hash = "sha256:111669eb6680c04495ae0c113a1476e3bf93a95761d23c7406b591c80a6490b1", size = 6468184, upload-time = "2026-05-26T13:31:26.911Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/44/fee070a16639d9869bb6a7e0f3a1b3946da1d66f32b9260b4d19cb90d7b2/pypdf-6.12.2-py3-none-any.whl", hash = "sha256:67b2699357a1f3f4c945940ea80826349ee507c9e2577724a14b4941982c104d", size = 343865, upload-time = "2026-05-26T13:31:25.068Z" },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.3" version = "9.0.3"
@@ -697,6 +1226,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
] ]
[[package]]
name = "starlette"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c5/bf/616a066c2760f6c2b1ae3437cc28149734d069fbb46511712beae118a68c/starlette-1.2.0.tar.gz", hash = "sha256:3c5a6b23fff42492914e93890bb80cbfea72dbf37de268eec06185d62a4ca553", size = 2668923, upload-time = "2026-05-28T11:42:50.568Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/85/492183764d5d01d4514be3730fdb8e228a80605783099551c51627578b5d/starlette-1.2.0-py3-none-any.whl", hash = "sha256:36e0c76ac59157e75dc4b3bdeafba97fb04eaf1878045f15dbef666a6f092ed7", size = 73213, upload-time = "2026-05-28T11:42:48.801Z" },
]
[[package]] [[package]]
name = "structlog" name = "structlog"
version = "25.5.0" version = "25.5.0"
@@ -764,3 +1306,98 @@ sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd177
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
] ]
[[package]]
name = "uvicorn"
version = "0.48.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" },
]
[[package]]
name = "yarl"
version = "1.24.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" },
{ url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" },
{ url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" },
{ url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" },
{ url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" },
{ url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" },
{ url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" },
{ url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" },
{ url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" },
{ url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" },
{ url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" },
{ url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" },
{ url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" },
{ url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" },
{ url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" },
{ url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" },
{ url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" },
{ url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" },
{ url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" },
{ url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" },
{ url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" },
{ url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" },
{ url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" },
{ url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" },
{ url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" },
{ url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" },
{ url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" },
{ url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" },
{ url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" },
{ url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" },
{ url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" },
{ url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" },
{ url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" },
{ url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" },
{ url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" },
{ url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" },
{ url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" },
{ url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" },
{ url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" },
{ url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" },
{ url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" },
{ url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" },
{ url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" },
{ url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" },
{ url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" },
{ url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" },
{ url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" },
{ url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" },
{ url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" },
{ url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" },
{ url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" },
{ url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" },
{ url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" },
{ url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" },
{ url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" },
{ url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" },
{ url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" },
{ url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" },
{ url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" },
{ url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" },
{ url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" },
{ url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" },
{ url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" },
]

169
vault.py
View File

@@ -1,55 +1,182 @@
""" """
vault.py — Cifrado de credenciales por tenant. vault.py — Cifrado de credenciales por tenant.
Cada tenant tiene sus credenciales cifradas con una clave derivada de: ## Arquitectura v3: Envelope Encryption con PBKDF2 + DEK por tenant
FERNET_KEY (global, del .env) + str(chat_id) (único por tenant)
Esto asegura que las credenciales de un tenant no se pueden descifrar Cada credencial se cifra en dos capas independientes:
con solo la FERNET_KEY — hace falta también el chat_id.
1. DEK (Data Encryption Key): 32 bytes aleatorios, única por operación de cifrado.
2. KEK→Wrapping Key: derivada de FERNET_KEY usando PBKDF2-HMAC-SHA256 con salt único
por tenant. El salt se genera al cifrar y se guarda junto al ciphertext.
La KEK (FERNET_KEY del .env) nunca cifra datos directamente — solo envuelve la DEK.
Formato credentials_enc v3:
JSON compacto: {"v":3,"s":"<salt_b64>","k":"<dek_enc_b64>","p":"<payload_enc_b64>"}
s = salt de 16 bytes (base64url) para derivar la wrapping key via PBKDF2
k = DEK cifrada con Fernet(wrapping_key)
p = {"usuario": ..., "password": ...} cifrado con Fernet(DEK)
Ventajas sobre v1 (SHA256):
- PBKDF2 (600k iter): derivar la wrapping key cuesta ~200ms — fuerza bruta inviable
- Salt único por tenant: mismo FERNET_KEY + mismo password → ciphertexts distintos
- DEK aleatoria: comprometer una credencial no ayuda con las demás
- Dos capas: robar la DB sin FERNET_KEY es inútil; robar FERNET_KEY sin la DB también
## Formato v1 (legacy, solo lectura)
Fernet(SHA256(FERNET_KEY + str(chat_id))) — mantenido para migración desde el script
`scripts/migrar_vault_v3.py`. No se generan nuevos v1.
""" """
from __future__ import annotations from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import json import json
import os
from functools import lru_cache
import structlog import structlog
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from utils.sanitize import scrub from utils.sanitize import scrub
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
# PBKDF2: NIST SP 800-132 (2023) recomienda mínimo 600k iteraciones para SHA-256
_PBKDF2_ITERATIONS = 600_000
_SALT_BYTES = 16
_DEK_BYTES = 32
def _derive_key(fernet_key: str, chat_id: int) -> bytes:
"""Deriva una clave Fernet única por tenant usando SHA-256(fernet_key + chat_id)."""
raw = (fernet_key + str(chat_id)).encode()
digest = hashlib.sha256(raw).digest()
return base64.urlsafe_b64encode(digest)
def _derive_wrapping_key(kek: str, salt: bytes) -> bytes:
"""
Deriva una wrapping key de 32 bytes usando PBKDF2-HMAC-SHA256.
~200ms intencionalmente — hace fuerza bruta inviable incluso con GPU.
"""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=_DEK_BYTES,
salt=salt,
iterations=_PBKDF2_ITERATIONS,
)
return base64.urlsafe_b64encode(kdf.derive(kek.encode("utf-8")))
@lru_cache(maxsize=256)
def _wrapping_key_cached(kek_fingerprint: str, salt_hex: str, kek: str) -> bytes:
"""
Cache de wrapping keys por (salt, kek) para no ejecutar PBKDF2 600k veces
en el mismo proceso cuando el poller revisa múltiples tenants.
kek_fingerprint es el SHA256[:8] de kek — sirve para invalidar entradas
si el FERNET_KEY cambia (nueva instancia del proceso).
"""
return _derive_wrapping_key(kek, bytes.fromhex(salt_hex))
def _get_wrapping_key(kek: str, salt: bytes) -> bytes:
"""Obtiene wrapping key del cache o la deriva si no está."""
fingerprint = hashlib.sha256(kek.encode()).hexdigest()[:8]
return _wrapping_key_cached(fingerprint, salt.hex(), kek)
# ── API pública ───────────────────────────────────────────────────────────────
def cifrar_credenciales(chat_id: int, usuario: str, password: str, fernet_key: str) -> str: def cifrar_credenciales(chat_id: int, usuario: str, password: str, fernet_key: str) -> str:
"""Retorna JSON cifrado con Fernet listo para guardar en DB.""" """
key = _derive_key(fernet_key, chat_id) Cifra credenciales con envelope encryption v3.
f = Fernet(key) Retorna JSON compacto listo para guardar en DB.
payload = json.dumps({"usuario": usuario, "password": password}).encode() """
encrypted = f.encrypt(payload).decode() # Capa 1: generar salt (para PBKDF2) y DEK aleatoria
logger.info("credenciales_cifradas", chat_id=chat_id) salt = os.urandom(_SALT_BYTES)
return encrypted dek_raw = os.urandom(_DEK_BYTES)
dek_b64 = base64.urlsafe_b64encode(dek_raw)
# Capa 2: cifrar DEK con wrapping key derivada de FERNET_KEY + salt
wrapping_key = _get_wrapping_key(fernet_key, salt)
f_wrap = Fernet(wrapping_key)
dek_enc = f_wrap.encrypt(dek_b64)
# Capa 3: cifrar payload con DEK
f_dek = Fernet(dek_b64)
payload_bytes = json.dumps({"usuario": usuario, "password": password}).encode()
payload_enc = f_dek.encrypt(payload_bytes)
envelope = {
"v": 3,
"s": base64.urlsafe_b64encode(salt).decode(),
"k": dek_enc.decode(),
"p": payload_enc.decode(),
}
logger.info("credenciales_cifradas_v3", chat_id=chat_id)
return json.dumps(envelope, separators=(",", ":"))
def descifrar_credenciales(chat_id: int, credentials_enc: str, fernet_key: str) -> tuple[str, str]: def descifrar_credenciales(chat_id: int, credentials_enc: str, fernet_key: str) -> tuple[str, str]:
""" """
Retorna (usuario, password). Descifra credenciales. Soporta v1 (legacy) y v3 (actual).
Levanta ValueError si no se puede descifrar (clave incorrecta o datos corruptos). Levanta ValueError si no se puede descifrar.
""" """
key = _derive_key(fernet_key, chat_id) stripped = credentials_enc.strip()
if stripped.startswith("{"):
return _descifrar_v3(chat_id, stripped, fernet_key)
return _descifrar_v1(chat_id, stripped, fernet_key)
def es_formato_v3(credentials_enc: str) -> bool:
"""Retorna True si las credenciales ya están en formato v3."""
return credentials_enc.strip().startswith("{")
# ── Implementaciones internas ─────────────────────────────────────────────────
def _descifrar_v3(chat_id: int, credentials_enc: str, fernet_key: str) -> tuple[str, str]:
try:
envelope = json.loads(credentials_enc)
if envelope.get("v") != 3:
raise ValueError(f"Versión desconocida: {envelope.get('v')}")
salt = base64.urlsafe_b64decode(envelope["s"])
dek_enc = envelope["k"].encode()
payload_enc = envelope["p"].encode()
# Descifrar DEK con wrapping key derivada de PBKDF2
wrapping_key = _get_wrapping_key(fernet_key, salt)
f_wrap = Fernet(wrapping_key)
dek_b64 = f_wrap.decrypt(dek_enc)
# Descifrar payload con DEK
f_dek = Fernet(dek_b64)
payload = json.loads(f_dek.decrypt(payload_enc).decode())
return payload["usuario"], payload["password"]
except (InvalidToken, KeyError, ValueError, json.JSONDecodeError) as e:
logger.error("error_descifrar_v3", chat_id=chat_id, error=scrub(str(e)))
raise ValueError(
f"No se pudieron descifrar credenciales (v3) para chat_id={chat_id}"
) from e
def _descifrar_v1(chat_id: int, credentials_enc: str, fernet_key: str) -> tuple[str, str]:
"""
Legacy: clave derivada con SHA256(fernet_key + chat_id).
Disponible para migración — no se generan nuevas v1.
"""
raw = (fernet_key + str(chat_id)).encode()
key = base64.urlsafe_b64encode(hashlib.sha256(raw).digest())
f = Fernet(key) f = Fernet(key)
try: try:
payload = json.loads(f.decrypt(credentials_enc.encode()).decode()) payload = json.loads(f.decrypt(credentials_enc.encode()).decode())
logger.warning("credenciales_v1_descifradas_migracion_pendiente", chat_id=chat_id)
return payload["usuario"], payload["password"] return payload["usuario"], payload["password"]
except (InvalidToken, KeyError, ValueError) as e: except (InvalidToken, KeyError, ValueError) as e:
logger.error("error_descifrar_credenciales", chat_id=chat_id, error=scrub(str(e))) logger.error("error_descifrar_v1", chat_id=chat_id, error=scrub(str(e)))
raise ValueError( raise ValueError(
f"No se pudieron descifrar credenciales para chat_id={chat_id}" f"No se pudieron descifrar credenciales (v1) para chat_id={chat_id}"
) from e ) from e