commit 2716edd5a01611136b086a6248bf9d8d69bc4dd1 Author: agent Date: Sat Jun 13 00:02:41 2026 +0200 feat: initial scaffold of osint_db (DuckDB source-of-truth service) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68f1b47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +data/ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +*.duckdb +*.duckdb.* +*.duckdb-wal +server.log diff --git a/app.md b/app.md new file mode 100644 index 0000000..82c079d --- /dev/null +++ b/app.md @@ -0,0 +1,156 @@ +--- +name: osint_db +lang: py +domain: osint +version: 0.1.0 +description: "Service FastAPI local (solo 127.0.0.1, puerto 8771) dueño único de la base DuckDB data/osint.duckdb: fuente de verdad estructurada del ecosistema OSINT. Indexa el vault de Obsidian osint (notes + persons/organizations/domains/cases/places con note_path), importa las maestras DAV de Xandikos (contacts, events), computa derivadas sin referencias a notas y renderiza tableros Markdown con bloques sentinel idempotentes dentro del vault." +tags: [service, osint, duckdb, obsidian, dav, vault] +uses_functions: + - list_obsidian_notes_py_obsidian + - read_obsidian_note_py_obsidian + - update_obsidian_note_py_obsidian + - create_obsidian_note_py_obsidian + - slugify_obsidian_name_py_obsidian + - dav_get_collection_py_infra + - dav_list_calendars_py_infra + - dav_collection_ctag_py_infra + - pass_get_secret_py_infra + - duckdb_query_readonly_py_infra + - render_markdown_table_py_core + - upsert_sentinel_block_py_core +uses_types: [] +framework: "fastapi" +entry_point: "server/main.py" +dir_path: "projects/osint/apps/osint_db" +repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/osint_db" +service: + port: 8771 + health_endpoint: /api/health + health_timeout_s: 3 + systemd_unit: null + systemd_scope: null + restart_policy: none + runtime: manual + pc_targets: + - lucas-linux + is_local_only: true +e2e_checks: + - id: tests + cmd: ".venv/bin/python -m pytest tests -q" + timeout_s: 120 + - id: vault_missing + cmd: ".venv/bin/python server/main.py --vault /no/existe --port 0" + expect_exit: 2 + timeout_s: 30 +--- + +## Qué es + +Service del project `osint` que posee en exclusiva la base DuckDB +`data/osint.duckdb`, la fuente de verdad estructurada del ecosistema OSINT +(vault de Obsidian `~/Obsidian/osint` + servidor Xandikos CardDAV/CalDAV). Un +plugin de Obsidian se construye en paralelo contra el contrato de esta API, por +eso los endpoints de datos responden SIEMPRE HTTP 200 con `status: ok|error` +en el body (el plugin parsea el body, no el código HTTP). + +## Arquitectura de datos (3 categorías) + +1. **Maestras con referencia a notas** (schema `main`): `notes` (índice + completo del vault) + `persons`, `organizations`, `domains`, `cases`, + `places` (fichas de nivel-1 de cada carpeta de entidades, excluyendo las + notas con prefijo `_`). Cada una lleva `note_path`: el path relativo de la + nota dentro del vault. +2. **Maestras DAV** (schema `main`): `contacts` y `events` importados de + Xandikos — fuente de verdad del lado agenda/calendario. `contacts.note_path` + se enlaza contra `persons` matcheando por UID `osint-`, por el + `dav_uid` extraído del campo `fuente` de la ficha, por teléfono normalizado + o por email. +3. **Derivadas** (schema `derived`): SOLO datos computados. **Regla dura: + ninguna tabla de `derived` lleva columna que referencie notas** (`note_path` + prohibido ahí; hay un test que lo verifica vía `information_schema`). Se + reconstruyen completas (DROP + CREATE) en cada ingest: + `derived.person_stats` (agregados por contexto/país/tag), + `derived.event_monthly` (eventos por calendario y mes) y + `derived.contact_link_quality` (contactos enlazados vs no, solo números). + +Single-writer: SOLO este service escribe la DuckDB. La conexión de escritura se +abre bajo demanda (migraciones, ingest, render) serializada con un lock de +proceso y se cierra al terminar; las lecturas de `/api/query` abren su propia +conexión `read_only` vía `duckdb_query_readonly`. + +## Migraciones + +`migrations/NNN_*.sql` numeradas, aditivas e idempotentes, aplicadas en orden +al arrancar. La tabla `_migrations` registra las aplicadas (regla +`db_migrations` adaptada a DuckDB). + +## Arrancar + +```bash +cd projects/osint/apps/osint_db +uv sync # primera vez: crea .venv +.venv/bin/python server/main.py # defaults: vault ~/Obsidian/osint, puerto 8771 +.venv/bin/python server/main.py --vault ~/Obsidian/osint --db data/osint.duckdb --port 8771 +``` + +Health check: `curl http://127.0.0.1:8771/api/health`. + +## Endpoints + +| Método | Ruta | Qué hace | +|---|---|---| +| GET | `/api/health` | `{"status":"ok","db_path":"...","tables":N}` | +| GET | `/api/tables` | inventario: schema, name, kind master/derived, row_count, columnas | +| POST | `/api/query` | `{sql, params, max_rows}` → respuesta exacta de `duckdb_query_readonly` (solo lectura) | +| GET | `/api/queries` | catálogo de queries con nombre (`server/named_queries.py`) | +| POST | `/api/query/named` | `{name, max_rows}` → misma shape que `/api/query` | +| POST | `/api/ingest/vault` | escanea el vault completo y reconstruye notes + entidades + derivadas | +| POST | `/api/ingest/dav` | baja Xandikos (CardDAV + cada calendario CalDAV), reconstruye contacts/events, enlaza y reconstruye derivadas | +| POST | `/api/render/note` | `{note_path, block_id, sql\|query, title?}` → tabla Markdown upsertada como bloque sentinel `osintdb` en la nota (la crea si no existe) | + +Queries con nombre incluidas: `personas_por_contexto`, `personas_recientes`, +`eventos_proximos`, `contactos_sin_nota`, `stats_personas`, +`calidad_enlace_contactos`, `eventos_por_mes`. + +## Configuración + +`server/config.py`: vault (`~/Obsidian/osint`), db (`data/osint.duckdb` +relativa a la app), DAV base/colecciones de Xandikos (los mismos valores que +`projects/osint/tools/sync_dav_to_osint.py`) y puerto 8771. Overrides por CLI: +`--vault`, `--db`, `--port`. La credencial DAV se resuelve SIEMPRE con +`pass_get_secret("dav/xandikos-enmanuel")`, nunca hardcodeada. + +## Seguridad + +- El vault y la base contienen datos personales sensibles: el server escucha + solo en `127.0.0.1` y `runtime: manual` (no se despliega a VPS, + `is_local_only: true`). +- `/api/query` es estrictamente read-only (conexión `read_only` de DuckDB). +- `/api/render/note` valida que el path destino no escapa del vault (realpath + bajo el realpath del vault). +- Vault inexistente al arrancar → error claro en stderr + exit 2. + +## Tests + +```bash +cd projects/osint/apps/osint_db +.venv/bin/python -m pytest tests -q +``` + +Vault temporal + DuckDB temporal, sin red: migraciones idempotentes, ingest del +vault con fixture (conteos, exclusión de sub-notas y `_`), extracción de +`dav_uid` desde `fuente`, `/api/query` ok/error/solo-lectura (siempre HTTP +200), catálogo y queries con nombre, inventario de tablas, regla dura derivadas +sin `note_path`, enlace contacto→ficha por teléfono y render de nota con bloque +sentinel idempotente + validación de inputs y path traversal. + +## Gotchas + +- DuckDB bloquea el archivo a un escritor exclusivo: mientras un ingest está + escribiendo, una lectura `read_only` concurrente puede devolver + `{"status":"error"}` por conflicto de lock. El cliente reintenta. +- `/api/ingest/dav` requiere `pass` desbloqueado (gpg-agent); si gpg está + bloqueado devuelve `{"status":"error"}` con el detalle, sin crash. +- Las notas con prefijo `_` (`_indice.md`, `_plantilla.md`) y las sub-notas de + documento (`personas//.md`) entran en `notes` pero NO cuentan como + entidades. diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..91832fa --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,112 @@ +-- Migración inicial de la base osint.duckdb. +-- +-- Tres categorías de datos: +-- 1. Tablas maestras con referencia a notas (schema main): índice del vault +-- (notes) + entidades estructuradas (persons, organizations, domains, +-- cases, places). Cada una lleva note_path: el path relativo de la nota +-- dentro del vault de Obsidian. +-- 2. Tablas maestras DAV (schema main): contacts y events importados del +-- servidor Xandikos (CardDAV/CalDAV) — fuente de verdad. +-- 3. Tablas derivadas (schema derived): SOLO datos computados. Regla dura: +-- ninguna tabla de derived lleva columna que referencie notas. Se +-- reconstruyen completas en cada ingest, por eso esta migración solo crea +-- el schema, no las tablas. + +-- Índice completo del vault: una fila por nota .md. +CREATE TABLE IF NOT EXISTS notes ( + note_path TEXT PRIMARY KEY, + slug TEXT, + tipo TEXT, + title TEXT, + mtime TIMESTAMP, + frontmatter JSON +); + +-- Fichas de persona (personas/.md), esquema canónico de CONVENTIONS.md 3b. +CREATE TABLE IF NOT EXISTS persons ( + slug TEXT PRIMARY KEY, + note_path TEXT, + nombre TEXT, + aliases JSON, + sexo TEXT, + fecha_nacimiento TEXT, + dni TEXT, + telefono TEXT, + email TEXT, + direccion TEXT, + pais TEXT, + contexto TEXT, + fuente TEXT, + dav_uid TEXT, + tags JSON, + updated_at TIMESTAMP +); + +-- Resto de entidades del vault: organizaciones, dominios, casos y lugares. +CREATE TABLE IF NOT EXISTS organizations ( + slug TEXT PRIMARY KEY, + note_path TEXT, + nombre TEXT, + tags JSON, + frontmatter JSON, + updated_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS domains ( + slug TEXT PRIMARY KEY, + note_path TEXT, + nombre TEXT, + tags JSON, + frontmatter JSON, + updated_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS cases ( + slug TEXT PRIMARY KEY, + note_path TEXT, + nombre TEXT, + tags JSON, + frontmatter JSON, + updated_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS places ( + slug TEXT PRIMARY KEY, + note_path TEXT, + nombre TEXT, + tags JSON, + frontmatter JSON, + updated_at TIMESTAMP +); + +-- Maestras DAV: contactos del addressbook CardDAV. note_path enlaza el +-- contacto con su ficha del vault cuando el matching lo consigue. +CREATE TABLE IF NOT EXISTS contacts ( + uid TEXT PRIMARY KEY, + collection TEXT, + etag TEXT, + fn TEXT, + tels JSON, + emails JSON, + raw TEXT, + note_path TEXT, + updated_at TIMESTAMP +); + +-- Maestras DAV: eventos de las colecciones CalDAV. +CREATE TABLE IF NOT EXISTS events ( + uid TEXT PRIMARY KEY, + calendar TEXT, + etag TEXT, + dtstart TEXT, + dtend TEXT, + all_day BOOLEAN, + summary TEXT, + location TEXT, + rrule TEXT, + raw TEXT, + updated_at TIMESTAMP +); + +-- Schema para las tablas derivadas (solo datos computados, sin note_path). +CREATE SCHEMA IF NOT EXISTS derived; diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9b4d864 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "osint-db" +version = "0.1.0" +description = "Service FastAPI local dueño único de la base DuckDB osint.duckdb: fuente de verdad estructurada del ecosistema OSINT (vault Obsidian + Xandikos CardDAV/CalDAV)." +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.110", + "uvicorn>=0.29", + "pyyaml>=6.0", + "duckdb>=1.0", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "httpx>=0.27", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..1ea0ffa --- /dev/null +++ b/server/__init__.py @@ -0,0 +1 @@ +"""Paquete server de la app osint_db (service FastAPI + capa DuckDB).""" diff --git a/server/config.py b/server/config.py new file mode 100644 index 0000000..b9c4392 --- /dev/null +++ b/server/config.py @@ -0,0 +1,55 @@ +"""Configuración del service osint_db. + +Valores por defecto del PC de enmanuel; todo es sobreescribible por CLI +(--vault, --db, --port) o construyendo un Config a mano en los tests. La +configuración DAV (base_url, colecciones, secreto en pass) es la misma que usan +los tools del proyecto (projects/osint/tools/sync_dav_to_osint.py). +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field + +# Directorio raíz de la app (padre de server/). +APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +DEFAULT_VAULT = os.path.expanduser("~/Obsidian/osint") +DEFAULT_DB = os.path.join(APP_DIR, "data", "osint.duckdb") +DEFAULT_PORT = 8771 + +# Configuración Xandikos (espejo de projects/osint/tools/sync_dav_to_osint.py). +DAV_BASE = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com" +DAV_USER = "enmanuel" +DAV_CONTACTS_COLLECTION = "/enmanuel/contacts/addressbook/" +DAV_CALENDAR_HOME = "/enmanuel/calendars/" +PASS_SECRET = "dav/xandikos-enmanuel" + +# Carpetas del vault que mapean a tablas maestras de entidades. +# (carpeta del vault, tipo de frontmatter, tabla destino) +ENTITY_FOLDERS = ( + ("personas", "persona", "persons"), + ("organizaciones", "organizacion", "organizations"), + ("dominios", "dominio", "domains"), + ("casos", "caso", "cases"), + ("lugares", "lugar", "places"), +) + +# Marker de los bloques sentinel que este service gestiona en notas del vault. +SENTINEL_MARKER = "osintdb" + + +@dataclass +class Config: + """Configuración efectiva del service (inyectable en tests).""" + + vault_dir: str = DEFAULT_VAULT + db_path: str = DEFAULT_DB + port: int = DEFAULT_PORT + host: str = "127.0.0.1" + dav_base: str = DAV_BASE + dav_user: str = DAV_USER + dav_contacts_collection: str = DAV_CONTACTS_COLLECTION + dav_calendar_home: str = DAV_CALENDAR_HOME + pass_secret: str = PASS_SECRET + entity_folders: tuple = field(default=ENTITY_FOLDERS) diff --git a/server/davparse.py b/server/davparse.py new file mode 100644 index 0000000..6614b10 --- /dev/null +++ b/server/davparse.py @@ -0,0 +1,133 @@ +"""Parseo ligero de vCard y de iCalendar para el ingest DAV. + +Lógica propia de la app (glue específico del dominio): el registry baja las +colecciones en bruto con dav_get_collection y aquí se extraen los campos +mínimos que las tablas maestras contacts y events necesitan. Mismo enfoque de +regex con unfold que projects/osint/tools/sync_dav_to_osint.py. +""" + +from __future__ import annotations + +import re + + +def _unfold(text: str) -> str: + """Deshace el folding de líneas (continuación con espacio o tab).""" + return re.sub(r"\r?\n[ \t]", "", text) + + +def _values(text: str, prop: str) -> list: + """Devuelve todos los valores de una propiedad (TEL, UID, DTSTART, ...). + + Acepta PROP;PARAMS:valor y PROP:valor (con prefijo itemN. opcional) y + decodifica los escapes simples (\\n, \\,, \\;, \\\\). + """ + vals = [] + for line in text.splitlines(): + m = re.match(rf"^(?:item\d+\.)?{prop}(?:;[^:]*)?:(.*)$", line, re.IGNORECASE) + if m: + v = m.group(1).strip() + v = ( + v.replace("\\n", "\n") + .replace("\\,", ",") + .replace("\\;", ";") + .replace("\\\\", "\\") + ).strip() + if v: + vals.append(v) + return vals + + +def _prop_line(text: str, prop: str) -> str: + """Devuelve la primera línea completa de una propiedad (con sus params).""" + for line in text.splitlines(): + if re.match(rf"^(?:item\d+\.)?{prop}(?:;|:)", line, re.IGNORECASE): + return line + return "" + + +def parse_vcard(vcard_text: str) -> dict: + """Extrae los campos mínimos de un vCard para la tabla contacts. + + Devuelve {uid, fn, tels, emails}. tels y emails son listas deduplicadas + preservando el orden. + """ + txt = _unfold(vcard_text) + return { + "uid": (_values(txt, "UID") or [""])[0], + "fn": (_values(txt, "FN") or [""])[0], + "tels": _dedup(_values(txt, "TEL")), + "emails": _dedup(_values(txt, "EMAIL")), + } + + +def parse_ical_events(ical_text: str) -> list: + """Extrae los VEVENT de un VCALENDAR para la tabla events. + + Devuelve una lista de dicts {uid, dtstart, dtend, all_day, summary, + location, rrule, raw}. dtstart/dtend se normalizan a ISO básico + (YYYY-MM-DD o YYYY-MM-DDTHH:MM:SS, conservando la Z de UTC si la trae). + all_day es True cuando DTSTART es VALUE=DATE (sin componente de hora). + """ + events = [] + txt = _unfold(ical_text) + for block in re.findall( + r"BEGIN:VEVENT(.*?)END:VEVENT", txt, re.DOTALL | re.IGNORECASE + ): + dtstart_line = _prop_line(block, "DTSTART") + dtstart_raw = (_values(block, "DTSTART") or [""])[0] + all_day = bool( + re.search(r";VALUE=DATE(?:;|:)", dtstart_line, re.IGNORECASE) + ) or bool(re.fullmatch(r"\d{8}", dtstart_raw)) + events.append( + { + "uid": (_values(block, "UID") or [""])[0], + "dtstart": _norm_dt(dtstart_raw), + "dtend": _norm_dt((_values(block, "DTEND") or [""])[0]), + "all_day": all_day, + "summary": (_values(block, "SUMMARY") or [""])[0], + "location": (_values(block, "LOCATION") or [None])[0], + "rrule": (_values(block, "RRULE") or [None])[0], + "raw": "BEGIN:VEVENT" + block + "END:VEVENT", + } + ) + return events + + +def _norm_dt(value: str) -> str: + """Normaliza un DATE/DATE-TIME de iCalendar a ISO legible. + + 20260115 -> 2026-01-15; 20260115T093000Z -> 2026-01-15T09:30:00Z. Valores + ya ISO o vacíos se devuelven tal cual. + """ + if not value: + return "" + m = re.fullmatch(r"(\d{4})(\d{2})(\d{2})", value) + if m: + return f"{m.group(1)}-{m.group(2)}-{m.group(3)}" + m = re.fullmatch(r"(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)", value) + if m: + return ( + f"{m.group(1)}-{m.group(2)}-{m.group(3)}" + f"T{m.group(4)}:{m.group(5)}:{m.group(6)}{m.group(7)}" + ) + return value + + +def _dedup(items: list) -> list: + """Deduplica una lista de strings preservando el orden (case-insensitive).""" + seen, out = set(), [] + for it in items: + key = str(it).strip().lower() + if key and key not in seen: + seen.add(key) + out.append(str(it).strip()) + return out + + +def norm_phone(p) -> str: + """Normaliza un teléfono a sus últimos 9 dígitos (número nacional ES).""" + if not p: + return "" + d = re.sub(r"\D", "", str(p)) + return d[-9:] if len(d) >= 9 else d diff --git a/server/db.py b/server/db.py new file mode 100644 index 0000000..91400de --- /dev/null +++ b/server/db.py @@ -0,0 +1,119 @@ +"""Capa de acceso a la base DuckDB: single-writer + migraciones. + +Single-writer: SOLO este service escribe osint.duckdb. DuckDB bloquea el +archivo a un escritor exclusivo, así que la conexión de escritura se abre bajo +demanda (migraciones, ingest, rebuild de derivadas), serializada con un lock de +proceso, y se cierra inmediatamente al terminar. Así, fuera de una escritura en +curso, las lecturas de /api/query pueden abrir su propia conexión read_only via +duckdb_query_readonly sin conflicto de lock. + +Migraciones (regla db_migrations adaptada a DuckDB): archivos numerados +migrations/NNN_*.sql, aditivos e idempotentes, aplicados en orden al arrancar. +La tabla _migrations registra cuáles ya se aplicaron para no repetirlas. +""" + +from __future__ import annotations + +import glob +import os +import threading +from contextlib import contextmanager + +import duckdb + +# Lock de proceso: serializa todas las aperturas de la conexión de escritura. +_WRITE_LOCK = threading.Lock() + +MIGRATIONS_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "migrations" +) + + +@contextmanager +def write_conn(db_path: str): + """Abre la conexión de escritura del service de forma exclusiva. + + Context manager: adquiere el lock de proceso, abre DuckDB en lectura y + escritura, cede la conexión y la cierra siempre al salir. Crea el + directorio padre del archivo si no existe. + """ + parent = os.path.dirname(os.path.abspath(db_path)) + if parent: + os.makedirs(parent, exist_ok=True) + with _WRITE_LOCK: + conn = duckdb.connect(db_path) + try: + yield conn + finally: + conn.close() + + +def apply_migrations(db_path: str) -> list: + """Aplica las migraciones pendientes en orden y devuelve las aplicadas. + + Lee migrations/NNN_*.sql ordenados por nombre, salta las que ya están + registradas en _migrations y ejecuta el resto dentro de la conexión de + escritura. Idempotente: una segunda llamada no aplica nada. + """ + applied: list = [] + files = sorted(glob.glob(os.path.join(MIGRATIONS_DIR, "*.sql"))) + with write_conn(db_path) as conn: + conn.execute( + "CREATE TABLE IF NOT EXISTS _migrations (" + "name TEXT PRIMARY KEY, applied_at TIMESTAMP DEFAULT now())" + ) + done = {row[0] for row in conn.execute("SELECT name FROM _migrations").fetchall()} + for path in files: + name = os.path.basename(path) + if name in done: + continue + with open(path, "r", encoding="utf-8") as fh: + sql = fh.read() + conn.execute("BEGIN") + try: + conn.execute(sql) + conn.execute("INSERT INTO _migrations (name) VALUES (?)", [name]) + conn.execute("COMMIT") + except Exception: + conn.execute("ROLLBACK") + raise + applied.append(name) + return applied + + +def list_tables(db_path: str) -> list: + """Inventario de tablas para /api/tables. + + Devuelve una lista de dicts {schema, name, kind, row_count, columns} para + las tablas de los schemas main y derived (excluye _migrations). kind es + 'master' para main y 'derived' para derived. + """ + out: list = [] + conn = duckdb.connect(db_path, read_only=True) + try: + tables = conn.execute( + "SELECT table_schema, table_name FROM information_schema.tables " + "WHERE table_schema IN ('main', 'derived') AND table_name != '_migrations' " + "ORDER BY table_schema, table_name" + ).fetchall() + for schema, name in tables: + cols = conn.execute( + "SELECT column_name, data_type FROM information_schema.columns " + "WHERE table_schema = ? AND table_name = ? ORDER BY ordinal_position", + [schema, name], + ).fetchall() + row_count = conn.execute( + f'SELECT COUNT(*) FROM "{schema}"."{name}"' + ).fetchone()[0] + out.append( + { + "schema": schema, + "name": name, + "kind": "derived" if schema == "derived" else "master", + "row_count": row_count, + "columns": [{"name": c, "type": t} for c, t in cols], + } + ) + finally: + conn.close() + return out diff --git a/server/derived.py b/server/derived.py new file mode 100644 index 0000000..14ff345 --- /dev/null +++ b/server/derived.py @@ -0,0 +1,83 @@ +"""Reconstrucción de las tablas derivadas (schema derived). + +Regla dura: las derivadas contienen SOLO datos computados — ninguna lleva +columna que referencie notas (note_path prohibido aquí). Se reconstruyen +completas (DROP + CREATE) en cada ingest, así que su contenido siempre refleja +el último estado de las maestras. +""" + +from __future__ import annotations + +import json + +# Nombres de las derivadas que este módulo gestiona (para reportar en ingest). +DERIVED_TABLES = ("person_stats", "event_monthly", "contact_link_quality") + + +def rebuild_derived(conn) -> list: + """Reconstruye todas las tablas derivadas sobre la conexión de escritura. + + Devuelve la lista de nombres cualificados de las tablas reconstruidas. + """ + _rebuild_person_stats(conn) + _rebuild_event_monthly(conn) + _rebuild_contact_link_quality(conn) + return [f"derived.{name}" for name in DERIVED_TABLES] + + +def _rebuild_person_stats(conn) -> None: + """Agregados de persons por contexto, pais y tag (sin note_path). + + Una fila por (dimension, valor) con el conteo de personas. Los tags se + expanden en Python desde el JSON de la columna tags para no depender de + funciones JSON del motor. + """ + rows = conn.execute("SELECT contexto, pais, tags FROM persons").fetchall() + counts: dict = {} + for contexto, pais, tags_json in rows: + counts[("contexto", contexto or "(sin)")] = ( + counts.get(("contexto", contexto or "(sin)"), 0) + 1 + ) + counts[("pais", pais or "(sin)")] = counts.get(("pais", pais or "(sin)"), 0) + 1 + try: + tags = json.loads(tags_json) if tags_json else [] + except (TypeError, ValueError): + tags = [] + if not isinstance(tags, list): + tags = [tags] + for tag in tags: + key = ("tag", str(tag)) + counts[key] = counts.get(key, 0) + 1 + + conn.execute("DROP TABLE IF EXISTS derived.person_stats") + conn.execute( + "CREATE TABLE derived.person_stats (dimension TEXT, valor TEXT, n BIGINT)" + ) + payload = [[dim, val, n] for (dim, val), n in sorted(counts.items())] + if payload: + conn.executemany( + "INSERT INTO derived.person_stats VALUES (?, ?, ?)", payload + ) + + +def _rebuild_event_monthly(conn) -> None: + """Conteo de eventos por calendario y mes (sin note_path).""" + conn.execute("DROP TABLE IF EXISTS derived.event_monthly") + conn.execute( + "CREATE TABLE derived.event_monthly AS " + "SELECT calendar, substr(dtstart, 1, 7) AS month, COUNT(*) AS n " + "FROM events WHERE dtstart IS NOT NULL " + "GROUP BY calendar, substr(dtstart, 1, 7) ORDER BY calendar, month" + ) + + +def _rebuild_contact_link_quality(conn) -> None: + """Calidad del enlace contacts -> persons: solo números, sin paths.""" + conn.execute("DROP TABLE IF EXISTS derived.contact_link_quality") + conn.execute( + "CREATE TABLE derived.contact_link_quality AS " + "SELECT COUNT(*) AS total, " + "COUNT(*) FILTER (WHERE note_path IS NOT NULL) AS linked, " + "COUNT(*) FILTER (WHERE note_path IS NULL) AS unlinked " + "FROM contacts" + ) diff --git a/server/ingest.py b/server/ingest.py new file mode 100644 index 0000000..6e72c3c --- /dev/null +++ b/server/ingest.py @@ -0,0 +1,364 @@ +"""Ingests del service osint_db: vault Obsidian y servidor DAV (Xandikos). + +Las tablas maestras se reconstruyen por reemplazo completo (DELETE + INSERT en +una transacción): el vault y Xandikos son las fuentes de verdad, así que cada +ingest deja la base exactamente como el origen. Tras cada ingest se re-enlazan +los contactos con sus fichas y se reconstruyen las tablas derivadas. +""" + +from __future__ import annotations + +import json +import os +import re +from datetime import datetime, timezone + +from . import davparse +from .config import Config +from .db import write_conn +from .derived import rebuild_derived +from .registry_bridge import ( + dav_get_collection, + dav_list_calendars, + list_obsidian_notes, + pass_get_secret, + read_obsidian_note, +) + +def _norm(value): + """Normaliza 'null'/''/None del frontmatter a None real.""" + if value is None: + return None + if isinstance(value, str) and value.strip().lower() in ("null", "none", ""): + return None + return value + + +def _as_str(value): + """Convierte un valor de frontmatter a str (o None), sin perder números.""" + v = _norm(value) + return None if v is None else str(v) + + +def _as_list(value) -> list: + """Convierte un valor de frontmatter a lista (los escalares se envuelven).""" + v = _norm(value) + if v is None: + return [] + return v if isinstance(v, list) else [v] + + +def _json(value) -> str: + """Serializa un valor a JSON compacto (sin escapar acentos).""" + return json.dumps(value, ensure_ascii=False, default=str) + + +def _dav_uid_from_fuente(fuente) -> str | None: + """Extrae el UID de Xandikos cuando fuente es 'Xandikos UID '.""" + if not fuente: + return None + m = re.search(r"Xandikos UID\s+(\S+)", str(fuente)) + return m.group(1) if m else None + + +def ingest_vault(cfg: Config) -> dict: + """Escanea el vault completo y reconstruye notes + tablas de entidades. + + Devuelve {status:'ok', notes:N, persons:N, organizations:N, domains:N, + cases:N, places:N, skipped_unreadable:N, derived_rebuilt:[...]}. + """ + if not os.path.isdir(cfg.vault_dir): + return {"status": "error", "error": f"vault no encontrado: {cfg.vault_dir}"} + + note_rows: list = [] + entity_rows: dict = {table: [] for _, _, table in cfg.entity_folders} + folder_to_table = {folder: table for folder, _, table in cfg.entity_folders} + skipped = 0 + + for abs_path in list_obsidian_notes(cfg.vault_dir): + rel_path = os.path.relpath(abs_path, cfg.vault_dir) + base = os.path.splitext(os.path.basename(abs_path))[0] + try: + note = read_obsidian_note(abs_path) + except Exception: # noqa: BLE001 — una nota corrupta no aborta el ingest + skipped += 1 + continue + fm = note.get("frontmatter") or {} + mtime = datetime.fromtimestamp(os.path.getmtime(abs_path), tz=timezone.utc) + slug = _as_str(fm.get("slug")) or base + note_rows.append( + [ + rel_path, + slug, + _as_str(fm.get("tipo")), + _as_str(fm.get("nombre")) or base, + mtime, + _json(fm), + ] + ) + + # Entidad estructurada: ficha de nivel-1 dentro de una carpeta de + # entidades (personas/.md, no personas//.md) y que + # no sea una nota de soporte (prefijo _). + top_folder = rel_path.split(os.sep)[0] + is_level1 = os.path.basename(os.path.dirname(abs_path)) == top_folder + if top_folder in folder_to_table and is_level1 and not base.startswith("_"): + table = folder_to_table[top_folder] + if table == "persons": + entity_rows[table].append( + [ + slug, + rel_path, + _as_str(fm.get("nombre")) or base, + _json(_as_list(fm.get("aliases"))), + _as_str(fm.get("sexo")), + _as_str(fm.get("fecha_nacimiento")), + _as_str(fm.get("dni")), + _as_str(fm.get("telefono")), + _as_str(fm.get("email")), + _as_str(fm.get("direccion")), + _as_str(fm.get("pais")), + _as_str(fm.get("contexto")), + _as_str(fm.get("fuente")), + _dav_uid_from_fuente(fm.get("fuente")), + _json(_as_list(fm.get("tags"))), + mtime, + ] + ) + else: + entity_rows[table].append( + [ + slug, + rel_path, + _as_str(fm.get("nombre")) or base, + _json(_as_list(fm.get("tags"))), + _json(fm), + mtime, + ] + ) + + derived_rebuilt: list = [] + with write_conn(cfg.db_path) as conn: + conn.execute("BEGIN") + try: + conn.execute("DELETE FROM notes") + if note_rows: + conn.executemany( + "INSERT INTO notes VALUES (?, ?, ?, ?, ?, ?)", note_rows + ) + conn.execute("DELETE FROM persons") + if entity_rows["persons"]: + conn.executemany( + "INSERT INTO persons VALUES " + "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + _dedup_by_slug(entity_rows["persons"]), + ) + for table in ("organizations", "domains", "cases", "places"): + conn.execute(f"DELETE FROM {table}") + if entity_rows[table]: + conn.executemany( + f"INSERT INTO {table} VALUES (?, ?, ?, ?, ?, ?)", + _dedup_by_slug(entity_rows[table]), + ) + _link_contacts(conn) + conn.execute("COMMIT") + except Exception: + conn.execute("ROLLBACK") + raise + derived_rebuilt = rebuild_derived(conn) + + return { + "status": "ok", + "notes": len(note_rows), + "persons": len(_dedup_by_slug(entity_rows["persons"])), + "organizations": len(_dedup_by_slug(entity_rows["organizations"])), + "domains": len(_dedup_by_slug(entity_rows["domains"])), + "cases": len(_dedup_by_slug(entity_rows["cases"])), + "places": len(_dedup_by_slug(entity_rows["places"])), + "skipped_unreadable": skipped, + "derived_rebuilt": derived_rebuilt, + } + + +def _dedup_by_slug(rows: list) -> list: + """Quita filas con slug repetido (gana la primera) para respetar la PK.""" + seen, out = set(), [] + for row in rows: + if row[0] in seen: + continue + seen.add(row[0]) + out.append(row) + return out + + +def ingest_dav(cfg: Config) -> dict: + """Baja las colecciones de Xandikos y reconstruye contacts + events. + + Devuelve {status:'ok', contacts:N, events:N, calendars:[...], + contacts_linked:N, derived_rebuilt:[...]} o {status:'error', error}. + """ + secret = pass_get_secret(cfg.pass_secret) + if secret.get("status") != "ok": + return { + "status": "error", + "error": f"pass no devolvió el secreto {cfg.pass_secret!r}: " + f"{secret.get('error')}", + } + pwd = secret["value"] # sensible: nunca logear + + coll = dav_get_collection( + cfg.dav_base, cfg.dav_user, pwd, cfg.dav_contacts_collection, "vcard" + ) + if coll.get("status") != "ok": + return { + "status": "error", + "error": f"CardDAV: {coll.get('error')} (http {coll.get('http_status')})", + } + + now = datetime.now(tz=timezone.utc) + contact_rows: list = [] + seen_uids: set = set() + for res in coll.get("resources", []): + parsed = davparse.parse_vcard(res.get("data", "")) + uid = parsed["uid"] or os.path.splitext(os.path.basename(res["href"]))[0] + if uid in seen_uids: + continue + seen_uids.add(uid) + contact_rows.append( + [ + uid, + cfg.dav_contacts_collection, + res.get("etag"), + parsed["fn"] or None, + _json(parsed["tels"]), + _json(parsed["emails"]), + res.get("data", ""), + None, # note_path se rellena en el enlace posterior + now, + ] + ) + + cals = dav_list_calendars(cfg.dav_base, cfg.dav_user, pwd, cfg.dav_calendar_home) + if cals.get("status") != "ok": + return { + "status": "error", + "error": f"CalDAV: {cals.get('error')} (http {cals.get('http_status')})", + } + + event_rows: list = [] + seen_event_uids: set = set() + calendar_names: list = [] + for cal in cals.get("calendars", []): + cal_name = cal.get("name") or cal.get("href", "").strip("/").rsplit("/", 1)[-1] + calendar_names.append(cal_name) + cal_coll = dav_get_collection( + cfg.dav_base, cfg.dav_user, pwd, cal["href"], "ical" + ) + if cal_coll.get("status") != "ok": + return { + "status": "error", + "error": f"CalDAV {cal_name}: {cal_coll.get('error')} " + f"(http {cal_coll.get('http_status')})", + } + for res in cal_coll.get("resources", []): + for ev in davparse.parse_ical_events(res.get("data", "")): + uid = ev["uid"] or os.path.splitext(os.path.basename(res["href"]))[0] + if uid in seen_event_uids: + continue + seen_event_uids.add(uid) + event_rows.append( + [ + uid, + cal_name, + res.get("etag"), + ev["dtstart"] or None, + ev["dtend"] or None, + ev["all_day"], + ev["summary"] or None, + ev["location"], + ev["rrule"], + ev["raw"], + now, + ] + ) + + with write_conn(cfg.db_path) as conn: + conn.execute("BEGIN") + try: + conn.execute("DELETE FROM contacts") + if contact_rows: + conn.executemany( + "INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + contact_rows, + ) + conn.execute("DELETE FROM events") + if event_rows: + conn.executemany( + "INSERT INTO events VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + event_rows, + ) + linked = _link_contacts(conn) + conn.execute("COMMIT") + except Exception: + conn.execute("ROLLBACK") + raise + derived_rebuilt = rebuild_derived(conn) + + return { + "status": "ok", + "contacts": len(contact_rows), + "events": len(event_rows), + "calendars": calendar_names, + "contacts_linked": linked, + "derived_rebuilt": derived_rebuilt, + } + + +def _link_contacts(conn) -> int: + """Enlaza contacts.note_path contra las fichas de persons. + + Orden de matching por fiabilidad: UID estilo osint- (creado por el + push del vault), dav_uid registrado en la ficha, teléfono normalizado y + por último email. Devuelve el número de contactos enlazados. + """ + persons = conn.execute( + "SELECT slug, note_path, telefono, email, dav_uid FROM persons" + ).fetchall() + by_slug, by_dav_uid, by_phone, by_email = {}, {}, {}, {} + for slug, note_path, telefono, email, dav_uid in persons: + by_slug[slug] = note_path + if dav_uid: + by_dav_uid.setdefault(dav_uid, note_path) + if telefono: + key = davparse.norm_phone(telefono) + if key: + by_phone.setdefault(key, note_path) + if email: + by_email.setdefault(str(email).strip().lower(), note_path) + + contacts = conn.execute("SELECT uid, tels, emails FROM contacts").fetchall() + linked = 0 + for uid, tels_json, emails_json in contacts: + note_path = None + if uid.startswith("osint-") and uid[len("osint-"):] in by_slug: + note_path = by_slug[uid[len("osint-"):]] + if note_path is None and uid in by_dav_uid: + note_path = by_dav_uid[uid] + if note_path is None: + for tel in json.loads(tels_json or "[]"): + hit = by_phone.get(davparse.norm_phone(tel)) + if hit: + note_path = hit + break + if note_path is None: + for em in json.loads(emails_json or "[]"): + hit = by_email.get(str(em).strip().lower()) + if hit: + note_path = hit + break + if note_path is not None: + conn.execute( + "UPDATE contacts SET note_path = ? WHERE uid = ?", [note_path, uid] + ) + linked += 1 + return linked diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..8a5852e --- /dev/null +++ b/server/main.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""Service FastAPI osint_db: dueño único de la base DuckDB data/osint.duckdb. + +La base es la fuente de verdad estructurada del ecosistema OSINT: índice del +vault de Obsidian (notes + entidades con note_path), maestras DAV importadas de +Xandikos (contacts, events) y derivadas computadas (schema derived, sin +referencias a notas). Solo este proceso escribe la base; las lecturas de +/api/query usan una conexión read_only separada (duckdb_query_readonly). + +Registry-first: el service NO reimplementa parseo de Markdown, protocolo DAV, +acceso a pass, ejecución read-only de DuckDB, render de tablas Markdown ni +bloques sentinel — importa esas funciones del registry (server/registry_bridge.py) +y solo aporta la lógica propia del dominio (mapeo vault→tablas, matching +contacto→ficha, derivadas y la API). + +Seguridad: el vault contiene datos personales sensibles, así que el server +escucha SOLO en 127.0.0.1 y /api/query es estrictamente de solo lectura. + +Uso: + .venv/bin/python server/main.py --vault ~/Obsidian/osint --port 8771 + +Contrato (el plugin de Obsidian parsea el body, no el código HTTP: los +endpoints de datos responden SIEMPRE 200 con status ok|error en el body): + GET /api/health estado + db_path + número de tablas + GET /api/tables inventario de tablas (schema, kind, filas, columnas) + POST /api/query SELECT arbitrario read-only {sql, params, max_rows} + GET /api/queries catálogo de queries con nombre + POST /api/query/named ejecuta una query del catálogo {name, max_rows} + POST /api/ingest/vault re-escanea el vault y reconstruye maestras+derivadas + POST /api/ingest/dav baja Xandikos y reconstruye contacts/events+derivadas + POST /api/render/note ejecuta query y la upserta como bloque sentinel en una nota +""" + +from __future__ import annotations + +import argparse +import os +import sys + +# Permite ejecutar tanto `python server/main.py` como importar `server.main`. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from fastapi import FastAPI # noqa: E402 +from pydantic import BaseModel, Field # noqa: E402 + +from server.config import SENTINEL_MARKER, Config # noqa: E402 +from server.db import apply_migrations, list_tables # noqa: E402 +from server.ingest import ingest_dav, ingest_vault # noqa: E402 +from server.named_queries import NAMED_QUERIES # noqa: E402 +from server.registry_bridge import ( # noqa: E402 + create_obsidian_note, + duckdb_query_readonly, + read_obsidian_note, + render_markdown_table, + update_obsidian_note, + upsert_sentinel_block, +) + +# Tope de filas que un render vuelca en una nota (las notas no son un export). +RENDER_MAX_ROWS = 200 + + +class QueryBody(BaseModel): + """Body de POST /api/query.""" + + sql: str + params: list = Field(default_factory=list) + max_rows: int = 500 + + +class NamedQueryBody(BaseModel): + """Body de POST /api/query/named.""" + + name: str + max_rows: int = 500 + + +class RenderNoteBody(BaseModel): + """Body de POST /api/render/note. Exactamente uno de sql|query.""" + + note_path: str + block_id: str + sql: str | None = None + query: str | None = None + title: str | None = None + + +def create_app(cfg: Config) -> FastAPI: + """Construye la app FastAPI con la configuración dada (inyectable en tests).""" + app = FastAPI(title="osint_db", docs_url=None, redoc_url=None) + + def run_readonly(sql: str, params: list, max_rows: int) -> dict: + """Ejecuta un SELECT con la conexión read_only del registry, acotado.""" + max_rows = max(1, min(int(max_rows), 10000)) + return duckdb_query_readonly(cfg.db_path, sql, params or [], max_rows) + + @app.get("/api/health") + def health() -> dict: + try: + tables = list_tables(cfg.db_path) + except Exception as e: # noqa: BLE001 + return {"status": "error", "db_path": cfg.db_path, "error": str(e)} + return {"status": "ok", "db_path": cfg.db_path, "tables": len(tables)} + + @app.get("/api/tables") + def tables() -> dict: + try: + return {"status": "ok", "tables": list_tables(cfg.db_path)} + except Exception as e: # noqa: BLE001 + return {"status": "error", "error": str(e)} + + @app.post("/api/query") + def query(body: QueryBody) -> dict: + return run_readonly(body.sql, body.params, body.max_rows) + + @app.get("/api/queries") + def queries() -> dict: + return { + "status": "ok", + "queries": [ + {"name": name, "description": q["description"], "sql": q["sql"]} + for name, q in NAMED_QUERIES.items() + ], + } + + @app.post("/api/query/named") + def query_named(body: NamedQueryBody) -> dict: + entry = NAMED_QUERIES.get(body.name) + if entry is None: + return { + "status": "error", + "error": f"query con nombre desconocida: {body.name!r} " + f"(disponibles: {', '.join(sorted(NAMED_QUERIES))})", + } + return run_readonly(entry["sql"], [], body.max_rows) + + @app.post("/api/ingest/vault") + def api_ingest_vault() -> dict: + try: + return ingest_vault(cfg) + except Exception as e: # noqa: BLE001 + return {"status": "error", "error": str(e)} + + @app.post("/api/ingest/dav") + def api_ingest_dav() -> dict: + try: + return ingest_dav(cfg) + except Exception as e: # noqa: BLE001 + return {"status": "error", "error": str(e)} + + @app.post("/api/render/note") + def render_note(body: RenderNoteBody) -> dict: + # Resolver el SQL: o viene literal, o por nombre del catálogo. + if bool(body.sql) == bool(body.query): + return { + "status": "error", + "error": "indica exactamente uno de los campos 'sql' o 'query' (named)", + } + if body.query: + entry = NAMED_QUERIES.get(body.query) + if entry is None: + return { + "status": "error", + "error": f"query con nombre desconocida: {body.query!r}", + } + sql = entry["sql"] + else: + sql = body.sql + + result = run_readonly(sql, [], RENDER_MAX_ROWS) + if result.get("status") != "ok": + return result + + table_md = render_markdown_table( + result["rows"], columns=result["columns"], max_rows=RENDER_MAX_ROWS + ) + content = table_md if table_md else "_(sin filas)_" + if body.title: + content = f"### {body.title}\n\n{content}" + + # La nota se referencia por path relativo al vault; el path no puede + # escapar del vault (mismo criterio anti-traversal que osint_web). + rel = body.note_path if body.note_path.endswith(".md") else body.note_path + ".md" + abs_path = os.path.abspath(os.path.join(cfg.vault_dir, rel)) + vault_real = os.path.realpath(cfg.vault_dir) + if not os.path.realpath(abs_path).startswith(vault_real + os.sep): + return {"status": "error", "error": f"note_path fuera del vault: {body.note_path!r}"} + + try: + if os.path.exists(abs_path): + note = read_obsidian_note(abs_path) + new_body = upsert_sentinel_block( + note.get("body", "") or "", + body.block_id, + content, + marker=SENTINEL_MARKER, + ) + update_obsidian_note(abs_path, body=new_body) + else: + new_body = upsert_sentinel_block( + "", body.block_id, content, marker=SENTINEL_MARKER + ) + create_obsidian_note( + cfg.vault_dir, + rel, + body=new_body, + frontmatter={"tipo": "tablero", "tags": ["osintdb"]}, + ) + except Exception as e: # noqa: BLE001 + return {"status": "error", "error": str(e)} + + return { + "status": "ok", + "note_path": rel, + "rows_rendered": result["row_count"], + } + + return app + + +def main() -> int: + """Punto de entrada CLI: valida el vault, migra la base y arranca uvicorn.""" + parser = argparse.ArgumentParser( + description="Service osint_db: DuckDB fuente de verdad del ecosistema OSINT." + ) + parser.add_argument("--vault", default=None, help="directorio del vault Obsidian") + parser.add_argument("--db", default=None, help="ruta del archivo osint.duckdb") + parser.add_argument("--port", type=int, default=None, help="puerto de escucha") + parser.add_argument( + "--host", + default="127.0.0.1", + help="host de escucha (datos sensibles: déjalo en 127.0.0.1)", + ) + args = parser.parse_args() + + cfg = Config() + if args.vault: + cfg.vault_dir = os.path.expanduser(args.vault) + if args.db: + cfg.db_path = os.path.expanduser(args.db) + if args.port is not None: + cfg.port = args.port + cfg.host = args.host + + if not os.path.isdir(cfg.vault_dir): + print( + f"ERROR: el vault no existe o no es un directorio: {cfg.vault_dir}", + file=sys.stderr, + ) + return 2 + + if cfg.host != "127.0.0.1": + print( + "AVISO: la base contiene datos personales sensibles; escuchar fuera " + "de 127.0.0.1 no está soportado.", + file=sys.stderr, + ) + + applied = apply_migrations(cfg.db_path) + if applied: + print(f"migraciones aplicadas: {', '.join(applied)}") + + import uvicorn + + app = create_app(cfg) + uvicorn.run(app, host=cfg.host, port=cfg.port, log_level="warning") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server/named_queries.py b/server/named_queries.py new file mode 100644 index 0000000..2e71617 --- /dev/null +++ b/server/named_queries.py @@ -0,0 +1,57 @@ +"""Catálogo de queries con nombre del service osint_db. + +Cada entrada mapea un nombre estable a {sql, description}. El plugin de +Obsidian (y /api/render/note) las invoca por nombre via POST /api/query/named, +así el SQL vive en un solo sitio y el cliente no necesita conocer el schema. +Todas son SELECT puros: se ejecutan por duckdb_query_readonly. +""" + +NAMED_QUERIES = { + "personas_por_contexto": { + "description": "Personas agrupadas por contexto (círculo de origen), de mayor a menor.", + "sql": ( + "SELECT coalesce(contexto, '(sin)') AS contexto, COUNT(*) AS personas " + "FROM persons GROUP BY 1 ORDER BY personas DESC, contexto" + ), + }, + "personas_recientes": { + "description": "Últimas 50 fichas de persona tocadas en el vault (por mtime de la nota).", + "sql": ( + "SELECT slug, nombre, contexto, note_path, updated_at " + "FROM persons ORDER BY updated_at DESC LIMIT 50" + ), + }, + "eventos_proximos": { + "description": "Próximos 50 eventos del calendario a partir de hoy, en orden cronológico.", + "sql": ( + "SELECT uid, calendar, dtstart, dtend, all_day, summary, location " + "FROM events WHERE dtstart >= strftime(now(), '%Y-%m-%d') " + "ORDER BY dtstart LIMIT 50" + ), + }, + "contactos_sin_nota": { + "description": "Contactos CardDAV que no enlazan con ninguna ficha del vault (note_path IS NULL).", + "sql": ( + "SELECT uid, fn, tels, emails FROM contacts " + "WHERE note_path IS NULL ORDER BY fn" + ), + }, + "stats_personas": { + "description": "Agregados de personas por dimensión (contexto, país, tag) desde derived.person_stats.", + "sql": ( + "SELECT dimension, valor, n FROM derived.person_stats " + "ORDER BY dimension, n DESC, valor" + ), + }, + "calidad_enlace_contactos": { + "description": "Resumen de contactos enlazados a persona vs sin enlazar (derived.contact_link_quality).", + "sql": "SELECT total, linked, unlinked FROM derived.contact_link_quality", + }, + "eventos_por_mes": { + "description": "Conteo de eventos por calendario y mes (derived.event_monthly).", + "sql": ( + "SELECT calendar, month, n FROM derived.event_monthly " + "ORDER BY month DESC, calendar" + ), + }, +} diff --git a/server/registry_bridge.py b/server/registry_bridge.py new file mode 100644 index 0000000..a003e7e --- /dev/null +++ b/server/registry_bridge.py @@ -0,0 +1,123 @@ +"""Puente con el fn_registry: localiza python/functions y expone las funciones. + +Registry-first: esta app NO reimplementa el parseo de notas Obsidian, el +protocolo DAV, el acceso a pass ni la ejecución read-only de DuckDB. Importa +las funciones del registry y las reexporta para el resto de módulos del +service. Toda función importada aquí está declarada en uses_functions del +app.md. + +La localización de python/functions evita paths hardcodeados de usuario: +prueba las variables de entorno FN_REGISTRY_FUNCTIONS y FN_REGISTRY_ROOT, +después sube por los directorios padre de este archivo hasta encontrar una +raíz que contenga python/functions/obsidian (la app vive en +/projects/osint/apps/osint_db/server/), y por último cae al layout +estándar del PC. +""" + +from __future__ import annotations + +import os +import sys + + +def _registry_functions_dir() -> str: + """Devuelve el directorio python/functions del fn_registry.""" + env_functions = os.environ.get("FN_REGISTRY_FUNCTIONS") + if env_functions and os.path.isdir(os.path.join(env_functions, "obsidian")): + return env_functions + + candidates: list[str] = [] + env_root = os.environ.get("FN_REGISTRY_ROOT") + if env_root: + candidates.append(env_root) + current = os.path.dirname(os.path.abspath(__file__)) + while True: + candidates.append(current) + parent = os.path.dirname(current) + if parent == current: + break + current = parent + candidates.append(os.path.expanduser("~/fn_registry")) + for root in candidates: + functions_dir = os.path.join(root, "python", "functions") + if os.path.isdir(os.path.join(functions_dir, "obsidian")): + return functions_dir + raise RuntimeError( + "no se encontró python/functions/obsidian subiendo desde " + f"{os.path.abspath(__file__)}; define FN_REGISTRY_ROOT con la raíz " + "del fn_registry" + ) + + +_FUNCTIONS_DIR = _registry_functions_dir() +if _FUNCTIONS_DIR not in sys.path: + sys.path.insert(0, _FUNCTIONS_DIR) + +# Grupo obsidian: CRUD de notas del vault + slugs. El __init__ del paquete +# obsidian es ligero (sin dependencias pesadas), así que se importa directo. +from obsidian import ( # noqa: E402 + create_obsidian_note, + list_obsidian_notes, + read_obsidian_note, + slugify_obsidian_name, + update_obsidian_note, +) + + +def _load_registry_fn(package: str, module_name: str, attr: str): + """Carga una función del registry por path, sin ejecutar el __init__ del paquete. + + Los __init__ de los paquetes infra y core importan TODAS sus funciones, y + algunas arrastran dependencias pesadas (Pillow, etc.) que este service no + necesita. Cargamos el archivo concreto con importlib (mismo patrón que + osint_web). Sigue siendo registry-first: se usa la función del registry sin + reimplementarla, solo se importa de forma quirúrgica. + """ + import importlib.util + + file_path = os.path.join(_FUNCTIONS_DIR, package, module_name + ".py") + spec = importlib.util.spec_from_file_location( + f"{package}_{module_name}", file_path + ) + if spec is None or spec.loader is None: # pragma: no cover - defensivo + raise ImportError(f"no se pudo cargar {file_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return getattr(module, attr) + + +# Grupo dav: lectura bulk de colecciones Xandikos (CardDAV/CalDAV). +dav_get_collection = _load_registry_fn("infra", "dav_get_collection", "dav_get_collection") +dav_list_calendars = _load_registry_fn("infra", "dav_list_calendars", "dav_list_calendars") +dav_collection_ctag = _load_registry_fn("infra", "dav_collection_ctag", "dav_collection_ctag") + +# Secretos via pass (credencial Xandikos, nunca hardcodeada). +pass_get_secret = _load_registry_fn("infra", "pass_get_secret", "pass_get_secret") + +# Lectura read-only de DuckDB (la conexión de /api/query, separada del writer). +duckdb_query_readonly = _load_registry_fn( + "infra", "duckdb_query_readonly", "duckdb_query_readonly" +) + +# Render de tablas Markdown + bloques sentinel idempotentes para las notas. +render_markdown_table = _load_registry_fn( + "core", "render_markdown_table", "render_markdown_table" +) +upsert_sentinel_block = _load_registry_fn( + "core", "upsert_sentinel_block", "upsert_sentinel_block" +) + +__all__ = [ + "create_obsidian_note", + "list_obsidian_notes", + "read_obsidian_note", + "slugify_obsidian_name", + "update_obsidian_note", + "dav_collection_ctag", + "dav_get_collection", + "dav_list_calendars", + "pass_get_secret", + "duckdb_query_readonly", + "render_markdown_table", + "upsert_sentinel_block", +] diff --git a/tests/test_osint_db.py b/tests/test_osint_db.py new file mode 100644 index 0000000..34594d7 --- /dev/null +++ b/tests/test_osint_db.py @@ -0,0 +1,356 @@ +"""Tests del service osint_db: migraciones, ingest del vault, API y render. + +Todo corre contra un vault temporal y una base DuckDB temporal, SIN red: el +ingest DAV no se ejercita aquí (requiere Xandikos + pass). El enlace +contacto→ficha sí se prueba insertando un contacto a mano y relanzando el +ingest del vault, que re-enlaza. +""" + +from __future__ import annotations + +import os +import sys +from datetime import datetime, timezone + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from server.config import Config # noqa: E402 +from server.db import apply_migrations, write_conn # noqa: E402 +from server.main import create_app # noqa: E402 + +PERSONA_MD = """--- +tipo: persona +nombre: "Ana García Pérez" +slug: ana-garcia-perez +aliases: ["Anita"] +sexo: mujer +fecha_nacimiento: 1990-04-12 +dni: 12345678Z +telefono: "+34 600 111 222" +email: ana@example.com +direccion: null +pais: españa +relaciones: [] +contexto: familia +fuente: "test fixture" +tags: [persona, osint] +--- + +## Notas +Ficha de prueba. +""" + +PERSONA2_MD = """--- +tipo: persona +nombre: "Luis Pérez" +slug: luis-perez +aliases: [] +sexo: hombre +fecha_nacimiento: null +dni: null +telefono: null +email: null +direccion: null +pais: null +relaciones: [] +contexto: movil +fuente: "Xandikos UID abc-123" +tags: [persona, osint, movil] +--- + +## Notas +""" + +ORG_MD = """--- +tipo: organizacion +nombre: "Acme S.L." +slug: acme-sl +tags: [organizacion, osint] +--- + +## Notas +""" + +DOC_MD = """--- +tipo: documento +doc_tipo: dni +--- + +Sub-nota de documento (NO debe contar como ficha de persona). +""" + + +@pytest.fixture() +def cfg(tmp_path): + """Vault temporal con fichas de fixture + base DuckDB temporal migrada.""" + vault = tmp_path / "vault" + (vault / "personas" / "ana-garcia-perez").mkdir(parents=True) + (vault / "organizaciones").mkdir() + (vault / "personas" / "ana-garcia-perez.md").write_text( + PERSONA_MD, encoding="utf-8" + ) + (vault / "personas" / "luis-perez.md").write_text(PERSONA2_MD, encoding="utf-8") + (vault / "personas" / "_plantilla.md").write_text( + "---\ntipo: plantilla\n---\n", encoding="utf-8" + ) + (vault / "personas" / "ana-garcia-perez" / "dni.md").write_text( + DOC_MD, encoding="utf-8" + ) + (vault / "organizaciones" / "acme-sl.md").write_text(ORG_MD, encoding="utf-8") + + config = Config( + vault_dir=str(vault), + db_path=str(tmp_path / "data" / "osint.duckdb"), + port=0, + ) + apply_migrations(config.db_path) + return config + + +@pytest.fixture() +def client(cfg): + return TestClient(create_app(cfg)) + + +def test_migrations_son_idempotentes(cfg): + """La segunda pasada de migraciones no aplica nada (tabla _migrations).""" + assert apply_migrations(cfg.db_path) == [] + + +def test_health(client, cfg): + r = client.get("/api/health").json() + assert r["status"] == "ok" + assert r["db_path"] == cfg.db_path + assert r["tables"] >= 8 + + +def test_ingest_vault_cuenta_entidades(client): + r = client.post("/api/ingest/vault").json() + assert r["status"] == "ok" + # 5 notas: 2 personas + plantilla + sub-nota documento + organización. + assert r["notes"] == 5 + # Solo las fichas de nivel-1 sin prefijo _ cuentan como persona. + assert r["persons"] == 2 + assert r["organizations"] == 1 + assert r["domains"] == 0 + assert sorted(r["derived_rebuilt"]) == [ + "derived.contact_link_quality", + "derived.event_monthly", + "derived.person_stats", + ] + + +def test_ingest_vault_extrae_dav_uid_de_fuente(client): + client.post("/api/ingest/vault") + r = client.post( + "/api/query", + json={"sql": "SELECT dav_uid FROM persons WHERE slug = 'luis-perez'"}, + ).json() + assert r["status"] == "ok" + assert r["rows"][0]["dav_uid"] == "abc-123" + + +def test_api_query_ok_y_error_siempre_http_200(client): + client.post("/api/ingest/vault") + ok = client.post( + "/api/query", + json={"sql": "SELECT slug, nombre FROM persons ORDER BY slug", "max_rows": 10}, + ) + assert ok.status_code == 200 + body = ok.json() + assert body["status"] == "ok" + assert body["columns"] == ["slug", "nombre"] + assert body["row_count"] == 2 + assert body["truncated"] is False + assert body["rows"][0]["slug"] == "ana-garcia-perez" + + err = client.post("/api/query", json={"sql": "SELECT * FROM tabla_que_no_existe"}) + assert err.status_code == 200 + assert err.json()["status"] == "error" + assert err.json()["error"] + + +def test_api_query_es_solo_lectura(client): + client.post("/api/ingest/vault") + r = client.post( + "/api/query", json={"sql": "DELETE FROM persons"} + ).json() + assert r["status"] == "error" + + +def test_catalogo_de_queries_con_nombre(client): + r = client.get("/api/queries").json() + assert r["status"] == "ok" + names = {q["name"] for q in r["queries"]} + assert { + "personas_por_contexto", + "personas_recientes", + "eventos_proximos", + "contactos_sin_nota", + "stats_personas", + } <= names + assert all(q["sql"] and q["description"] for q in r["queries"]) + + +def test_query_named_ok_y_desconocida(client): + client.post("/api/ingest/vault") + r = client.post( + "/api/query/named", json={"name": "personas_por_contexto"} + ).json() + assert r["status"] == "ok" + contextos = {row["contexto"]: row["personas"] for row in r["rows"]} + assert contextos == {"familia": 1, "movil": 1} + + bad = client.post("/api/query/named", json={"name": "no_existe"}).json() + assert bad["status"] == "error" + + +def test_tables_inventario(client): + client.post("/api/ingest/vault") + r = client.get("/api/tables").json() + assert r["status"] == "ok" + by_name = {(t["schema"], t["name"]): t for t in r["tables"]} + persons = by_name[("main", "persons")] + assert persons["kind"] == "master" + assert persons["row_count"] == 2 + assert {"name": "note_path", "type": "VARCHAR"} in persons["columns"] + stats = by_name[("derived", "person_stats")] + assert stats["kind"] == "derived" + assert ("main", "_migrations") not in by_name + + +def test_derivadas_sin_note_path(client): + """Regla dura: ninguna tabla del schema derived referencia notas.""" + client.post("/api/ingest/vault") + r = client.post( + "/api/query", + json={ + "sql": ( + "SELECT table_name, column_name FROM information_schema.columns " + "WHERE table_schema = 'derived' AND column_name LIKE '%note%'" + ) + }, + ).json() + assert r["status"] == "ok" + assert r["rows"] == [] + # Y las tres derivadas existen de verdad. + t = client.post( + "/api/query", + json={ + "sql": ( + "SELECT table_name FROM information_schema.tables " + "WHERE table_schema = 'derived' ORDER BY table_name" + ) + }, + ).json() + assert [row["table_name"] for row in t["rows"]] == [ + "contact_link_quality", + "event_monthly", + "person_stats", + ] + + +def test_link_contacts_por_telefono(client, cfg): + """Un contacto con teléfono que casa con una ficha queda enlazado al re-ingestar.""" + client.post("/api/ingest/vault") + now = datetime.now(tz=timezone.utc) + with write_conn(cfg.db_path) as conn: + conn.execute( + "INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + "uid-movil-1", + "/enmanuel/contacts/addressbook/", + "etag1", + "Ana G.", + '["600111222"]', + "[]", + "BEGIN:VCARD...", + None, + now, + ], + ) + conn.execute( + "INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + "uid-movil-2", + "/enmanuel/contacts/addressbook/", + "etag2", + "Desconocido", + '["699999999"]', + "[]", + "BEGIN:VCARD...", + None, + now, + ], + ) + # El ingest del vault re-enlaza contacts y reconstruye derivadas. + client.post("/api/ingest/vault") + r = client.post( + "/api/query", + json={"sql": "SELECT uid, note_path FROM contacts ORDER BY uid"}, + ).json() + rows = {row["uid"]: row["note_path"] for row in r["rows"]} + assert rows["uid-movil-1"] == os.path.join("personas", "ana-garcia-perez.md") + assert rows["uid-movil-2"] is None + + q = client.post("/api/query/named", json={"name": "contactos_sin_nota"}).json() + assert [row["uid"] for row in q["rows"]] == ["uid-movil-2"] + + quality = client.post( + "/api/query/named", json={"name": "calidad_enlace_contactos"} + ).json() + assert quality["rows"] == [{"total": 2, "linked": 1, "unlinked": 1}] + + +def test_render_note_crea_bloque_sentinel_y_es_idempotente(client, cfg): + client.post("/api/ingest/vault") + body = { + "note_path": "tableros/personas.md", + "block_id": "personas", + "query": "personas_por_contexto", + "title": "Personas por contexto", + } + r = client.post("/api/render/note", json=body).json() + assert r["status"] == "ok" + assert r["note_path"] == "tableros/personas.md" + assert r["rows_rendered"] == 2 + + note_file = os.path.join(cfg.vault_dir, "tableros", "personas.md") + content = open(note_file, encoding="utf-8").read() + assert "" in content + assert "" in content + assert "### Personas por contexto" in content + assert "| contexto | personas |" in content + assert "| familia | 1 |" in content + + # Idempotente: un segundo render no duplica el bloque ni la tabla. + r2 = client.post("/api/render/note", json=body).json() + assert r2["status"] == "ok" + content2 = open(note_file, encoding="utf-8").read() + assert content2.count("") == 1 + assert content2.count("| familia | 1 |") == 1 + + +def test_render_note_valida_inputs(client): + client.post("/api/ingest/vault") + # Ni sql ni query. + r = client.post( + "/api/render/note", json={"note_path": "t.md", "block_id": "x"} + ).json() + assert r["status"] == "error" + # Query con nombre desconocida. + r = client.post( + "/api/render/note", + json={"note_path": "t.md", "block_id": "x", "query": "nope"}, + ).json() + assert r["status"] == "error" + # Path traversal fuera del vault. + r = client.post( + "/api/render/note", + json={"note_path": "../fuera.md", "block_id": "x", "query": "stats_personas"}, + ).json() + assert r["status"] == "error" + assert "fuera del vault" in r["error"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bb824ac --- /dev/null +++ b/uv.lock @@ -0,0 +1,563 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[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]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[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]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "duckdb" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/00/d579dcb2a536b6ea3a2563cdad6844f77d81a9b2d4b22a858097f2468acf/duckdb-1.5.3.tar.gz", hash = "sha256:df39428eb130faa35ae96fd35245bdeae6ecf43936250b116b5fead568eb9f16", size = 18026640, upload-time = "2026-05-20T11:55:31.901Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/4a/06a81e9e3c01634760073b6a8911fccee2e33b23569f479971f910228972/duckdb-1.5.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6ef8faf121d7b3ad95aab1c3ce31169a28be49da75abfa6099a1bec2e9a70189", size = 32575938, upload-time = "2026-05-20T11:53:56.365Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ed/03ccf15147db7468f65891b5daa8489aa755d8cfcf75b24e7a4e4720e28c/duckdb-1.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd00f70231951a619908471b7b6397232ff3be8ccd1f49a47f1a2ccac59eaba1", size = 17276167, upload-time = "2026-05-20T11:53:59.558Z" }, + { url = "https://files.pythonhosted.org/packages/80/bd/ffc9e7a52731eec047dd49d45f029580dc8f3a6f8e8802a9e1c4138b0f8a/duckdb-1.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:50379b85f3a0a169478d54880ef8bf971ecaa85772d05eeaa617d720c7704741", size = 15425037, upload-time = "2026-05-20T11:54:01.894Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5d/44209bcd4ad47feb84831edd3f269397e03167efbb86aee2d102b2f71252/duckdb-1.5.3-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d37650ec3ec8a951400ea12dc77edaea88e0baeda34801792776f95f2f922f4f", size = 19306975, upload-time = "2026-05-20T11:54:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/76/5c/bdb735011131c429cdb6d9e12e12fc8f1c8622158890d76a25819a58efc2/duckdb-1.5.3-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3fb3bad9bc1a3e101d66d33269142ce075dc3d75202ba74ba97d7e44c50b9cd", size = 21416979, upload-time = "2026-05-20T11:54:07.429Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c2/929666ff6d71b15b128c6f800d893fb5589c7b6430ff9ecdcd4e6d175bea/duckdb-1.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:fdc65233f0fcf9022e4c6a8ba2ba751a79deb291501073d660afb1aa9874051f", size = 13101915, upload-time = "2026-05-20T11:54:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fc/a8a89c6c73f31c2b58c6abbc2f543e0b736042dd5ef7cc1784c24ec31428/duckdb-1.5.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:341a2672e2551ba51c95c1898f0ade983e76675e79038ccb16342c3d6cfb82d7", size = 32583465, upload-time = "2026-05-20T11:54:13.132Z" }, + { url = "https://files.pythonhosted.org/packages/63/f1/3423a2f523dd034e505d4a5dd8e210ae577212e152598dc13b6a5e736e1b/duckdb-1.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c9e8fa408705081160ede7ead238d16e73a36b8561b700f2bf2d650ae48e7b92", size = 17278520, upload-time = "2026-05-20T11:54:16.368Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1a/7bf5ba1b7ea520557e6b2dbee1c85abab016bdac0c1779d9d0ef76c87300/duckdb-1.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:70a18f932cf6d87bd0e554613657a515c1443a1724aacfc7ec5137dd28698b03", size = 15424794, upload-time = "2026-05-20T11:54:19.891Z" }, + { url = "https://files.pythonhosted.org/packages/ad/16/ce4b1e386e45fab0268edbf1b85bace20e9437589e9edb2bd5f9a226fa44/duckdb-1.5.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e80eb4d0fb59869cb2c7d7ef494c07fb92014fe8e77d96c170cd1ebc1488a708", size = 19306666, upload-time = "2026-05-20T11:54:22.77Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/651f8453f26931e8061b7e27b3090f868868185814ecb9216d0bd71ec8ef/duckdb-1.5.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3248b49cd835ea322574bc6aac0ae7a83be85547f49d4f5f5777cb380ee6627f", size = 21418306, upload-time = "2026-05-20T11:54:25.616Z" }, + { url = "https://files.pythonhosted.org/packages/bc/64/e1ffebf010b1631a6fef8d1508f46d4eab3e97c18729af986bb796fa8452/duckdb-1.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:f4eff89c12c3a362efa012262e57b7b4ab904a7f79bad9178fe365510077abe8", size = 13101423, upload-time = "2026-05-20T11:54:28.107Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/b1d4e34f9658cc0e13d7aae581ab82643f50a548d5aee8767f0c587cc3a4/duckdb-1.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:75d13308c9da3ee431d1e72b8ab720aa74a1b3e9159d4124cb62435924496334", size = 13951740, upload-time = "2026-05-20T11:54:30.886Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/2e34929b16c8d544ef664fad8f7f3a2a9db05746aae1e7c8c4ee3a8b23e4/duckdb-1.5.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ff11a457258148337ef9a392148a8cdbd1069b6c27c21958816c7b67fe6c542d", size = 32626494, upload-time = "2026-05-20T11:54:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/3a/53/3af681793d03771365ae3e2215331151c196a3ac8193f613344840694671/duckdb-1.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fd25f533cb1b6b2c84cc767a9a9bab7769bb1aa44571a2a0bfc91ac3e4a38ac", size = 17301121, upload-time = "2026-05-20T11:54:36.928Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/c80af1eac2ab5d35fc2c372ef0a84668842e549fbbf7799277b3fccf3e39/duckdb-1.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10960400ed60cdf0fe05bab2086fa8eb733889cb0ceca18d07ff9a00c0e0be7b", size = 15449283, upload-time = "2026-05-20T11:54:39.777Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/c63af233c9f761bf5178a5210437e1bc6bcb30fa8a9073de6398cfb12c03/duckdb-1.5.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5f18e7561403054433706c187589e86629a7af09a7efc23a06a8b308e6acc68", size = 19332762, upload-time = "2026-05-20T11:54:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/21/cc/2d77af4fff86012f334ef82e6d54a995a86c8745e58074f1218ed7d25171/duckdb-1.5.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fb7516255a8764545e30f7efacea408cc847764a3027b3b0b3e7d1a7bebbc5c", size = 21453290, upload-time = "2026-05-20T11:54:45.272Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5e/9bc4817a98feb4dab83e56f2245cd3a30d00ee646d4dec7926464e2b3f28/duckdb-1.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:8001eccbc28be244dfd04d708526f34ddd6460b47a8aeb5d0e39d6f7f9e3fe15", size = 13118308, upload-time = "2026-05-20T11:54:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/e3f32e4e53e2450ddb1db8312a17d1ce455d60cc4941b6ad2cfc908794b0/duckdb-1.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:6d2835e39bb6af73891f73c0f8d4324f98afe00d0b00c6d34b2a582c2256cbb0", size = 13927187, upload-time = "2026-05-20T11:54:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/a528eb09d8be51954c485864bd06753e616939a080cbc3dd4417e8c94a57/duckdb-1.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e75a6122c12579a99848517f6f00a4e342aebda3590c30fe9b5cc5f39d5e6afc", size = 32626254, upload-time = "2026-05-20T11:54:53.65Z" }, + { url = "https://files.pythonhosted.org/packages/ec/3c/1534c0a6db347c05eb7d0f6ecfb7aefbe74cbff398e4892a8fd1903a20e8/duckdb-1.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd3963c1cb9d9567777f4a898a9dbe388a2fe9724681801b1e7d6d93eecf1b76", size = 17300917, upload-time = "2026-05-20T11:54:56.628Z" }, + { url = "https://files.pythonhosted.org/packages/23/fa/beafb91e6e152d2161c4a9cbc472334c87607eb61ad7104b5a7fa8d8d7b1/duckdb-1.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3d5db8c0b55e072cf437948ebb5d7e23d7b9d03d905fa5f9145583e65aa447f7", size = 15449411, upload-time = "2026-05-20T11:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/50/0a/49b6fe04e2fcd63729eb607dadd44818dde77342a4f5ce086c6c92f1dd4d/duckdb-1.5.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ce80aed7a538422129a57eaca9141e3afb51f8bf562b1908b1576c9725b5b22", size = 19333120, upload-time = "2026-05-20T11:55:01.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/4c/0907c3f76adb9dd90e67610b31e0304a35814e65c4c41a354a262c09b885/duckdb-1.5.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787df63824f07bf18022dbc3b8ca4b2bfab0ebe616464f55c6e8cd0f59ea762e", size = 21453266, upload-time = "2026-05-20T11:55:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9c/d2f23a7803ddbbd9413f7572ecf66a15120ed5ced7ce5c73e698c1406b76/duckdb-1.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb5bb5dcdd09d62ee60f0ddbbef918e71cce304ffe28428b1131949d39ffaabf", size = 13118640, upload-time = "2026-05-20T11:55:07.389Z" }, + { url = "https://files.pythonhosted.org/packages/27/d5/7ba2316415bcdab6edd765bbbe35c2ca8a3800f2fe695cd70e3cdb997f09/duckdb-1.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:2fa17ecdd5d3db122836cb71bb93601c2106a3be883c17dffddc02fbf3fa7888", size = 13926409, upload-time = "2026-05-20T11:55:10.166Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c2/d4b6f8a5e4d3bc25773be6da76a99d9661ebbf3552c007c460d2dd59dbf8/duckdb-1.5.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4bfa9a4dadf71e83e2c4eaca2f9421c82a54defecc1b0b4c0be95e2389dec4fe", size = 32636685, upload-time = "2026-05-20T11:55:13.158Z" }, + { url = "https://files.pythonhosted.org/packages/42/58/e835c8298979d29db7a62cb5acc29e9b57aeaca7cdde2fcd3ac980f5cb18/duckdb-1.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aea7baf67ad7e1829ac76f67d7dcbd7fb1f57c3eb179d55ac30952df4709ae30", size = 17308134, upload-time = "2026-05-20T11:55:16.194Z" }, + { url = "https://files.pythonhosted.org/packages/c9/46/617b51363f5613418c8b224b3cce16b58e6dde80904566bec232579c1d4e/duckdb-1.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b0b4f088a65d77e1217ce5d7eff889e63fedc44281200d899ff47c84d8ff836", size = 15449891, upload-time = "2026-05-20T11:55:18.687Z" }, + { url = "https://files.pythonhosted.org/packages/b3/72/354146656e8d9ba3853d3a5ee80a481b8c5f70edfc3d5ae80a8c4479c967/duckdb-1.5.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe8d0c1f6a120aa03fa6e0d03897c71a1842e6cf7afd31d181348391f7108fe1", size = 19338499, upload-time = "2026-05-20T11:55:21.34Z" }, + { url = "https://files.pythonhosted.org/packages/56/8f/65fc623b51448f2bfba1a9ec6ab3debb4664c0876c0113a5e782600b53ac/duckdb-1.5.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0405eae18ec6e8210a471c97dbfe87a7e4d605274b7fe572a1f276e92158f13", size = 21455828, upload-time = "2026-05-20T11:55:23.847Z" }, + { url = "https://files.pythonhosted.org/packages/2b/db/d0274cbe9f5fe219f77c0bdf900ac77103569e83c102a4225ce04cbc607d/duckdb-1.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:33ae08b3e818d7613d8936744b67718c2062c2f530376895bfd89efb51b81538", size = 13640011, upload-time = "2026-05-20T11:55:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/07/5d/8f1899b8bef291caf953992fcd6c24df9f29387a35645e58c2504a5ca473/duckdb-1.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:746433e49bbc667b4df283153415fbe37e9083e0eff6c3cd6e54de7536869cd4", size = 14411554, upload-time = "2026-05-20T11:55:29.037Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[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 = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +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" }, +] + +[[package]] +name = "osint-db" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "duckdb" }, + { name = "fastapi" }, + { name = "pyyaml" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "duckdb", specifier = ">=1.0" }, + { name = "fastapi", specifier = ">=0.110" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "uvicorn", specifier = ">=0.29" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "pytest", specifier = ">=8.0" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +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" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +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" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +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/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +]