commit 41566ab5fb760bf2420e072bf9def5f85e7d9e43 Author: Roman Date: Tue Sep 22 10:05:47 2026 +0000 v0.1.0 — Réécriture complète de OhmStreaming Nouvelle version réécrite de zéro : recherche multi-sources (Vostfree, French-Manga), extraction 2 niveaux, proxy vidéo intégré, streaming/téléchargement HLS, métadonnées Kitsu, bibliothèque locale, comptes JWT + administration, découverte fusionnée, indexeur Torznab (Sonarr/Prowlarr). diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..431b493 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Copier en .env et adapter. Toutes les variables sont préfixées OHM_. + +# OBLIGATOIRE en production : clé de signature des tokens (32+ caractères) +OHM_SECRET_KEY=change-me-in-production + +# Chemins +# OHM_DATA_DIR=./data +# OHM_DOWNLOAD_DIR=./downloads +# OHM_DATABASE_PATH=./data/ohm.db + +# Téléchargements +# OHM_MAX_PARALLEL_DOWNLOADS=3 + +# Scraping +# OHM_HTTP_TIMEOUT=20 +# OHM_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0" + +# Métadonnées Kitsu +# OHM_METADATA_CACHE_TTL_HOURS=72 + +# Auth +# OHM_ACCESS_TOKEN_TTL_MINUTES=15 +# OHM_REFRESH_TOKEN_TTL_DAYS=30 + +# OHM_DEBUG=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8210eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.pyc +data/ +downloads/ +.env +.pytest_cache/ +.ruff_cache/ +.plasma/ diff --git a/Projet_descriptions.md b/Projet_descriptions.md new file mode 100644 index 0000000..7fcfccb --- /dev/null +++ b/Projet_descriptions.md @@ -0,0 +1,96 @@ +# Ohm Stream Downloader — Description fonctionnelle + +> Cahier des charges pour une réécriture à partir de zéro. + +## 🎯 Le but du projet + +Une **application web auto-hébergée** (pensée pour un homelab, usage personnel) qui sert de **centre de contrôle unique** pour les contenus vidéo en français (animes, séries TV, VOSTFR) : + +1. **Découvrir** du contenu en cherchant sur plusieurs sites/sources en même temps, +2. **Regarder** directement dans l'application via un lecteur intégré, +3. **Télécharger** des épisodes ou des saisons complètes, +4. **Ne rien rater** : suivre des titres et télécharger automatiquement chaque nouvel épisode dès sa sortie. + +L'application agrège donc le rôle de moteur de recherche, de plateforme de streaming, de gestionnaire de téléchargements et d'outil d'automatisation. + +## 👤 Parcours utilisateur type + +1. L'utilisateur se connecte (ou s'inscrit). +2. Il lance une **recherche unifiée** : une seule requête interroge tous les sites d'animes et de séries activés, les résultats sont fusionnés. +3. Il consulte la **fiche détaillée** d'un titre : poster, bannière, synopsis, genres, note, année, nombre d'épisodes, liste des saisons et épisodes. +4. Depuis la fiche, il peut : + - **streamer** un épisode dans le lecteur intégré, + - **télécharger** un épisode précis ou une **saison entière** d'un coup, + - **l'ajouter à sa watchlist** pour un suivi automatique, + - **l'ajouter aux favoris**. +5. Il suit l'avancement des téléchargements en temps réel, puis **regarde les fichiers téléchargés** dans une bibliothèque locale (streaming depuis le serveur, reprise de lecture, épisode suivant). + +## ⚙️ Fonctionnalités détaillées + +### 1. Recherche multi-sources +- Sources « animes » : environ 5 sites francophones (Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga). +- Sources « séries/films » : environ 2 sites (FS7/French-Stream, Zone-Téléchargement). +- Chaque source est un **module interchangeable** avec le même contrat : chercher, lister les épisodes, récupérer les métadonnées, extraire le lien vidéo réel. +- Résolution automatique : pour une URL donnée, le système trouve tout seul le bon module (site → hébergeur vidéo → générique). +- **Extraction en 2 niveaux** : la page de l'épisode contient des lecteurs embarqués hébergés ailleurs (une quinzaine d'hébergeurs supportés : DoodStream, VidMoly, 1fichier, Uptobox, Sibnet, Uqload, SendVid, etc.). Le système sait résoudre l'URL directe du fichier pour chacun d'eux. + +### 2. Métadonnées enrichies +- Les infos de base viennent du site scrappé, puis sont **enrichies/complétées** via une base externe de données anime (Kitsu, en alternative à MAL) : synopsis, genres, notes, année, images. +- Fusion intelligente : on ne remplit que les champs manquants, avec normalisation des formats entre sources. +- **Cache des métadonnées** pour limiter les appels externes. + +### 3. Streaming et lecteur +- Lecteur vidéo intégré pour les liens extraits des hébergeurs (avec contournement des protections courantes de ces hébergeurs). +- **Bibliothèque locale** : tous les fichiers téléchargés sont streamables depuis le serveur (page « watch » avec reprise de lecture, navigation épisode suivant, streaming avec support des requêtes de plage pour l'avance rapide). + +### 4. Gestionnaire de téléchargements +- File d'attente avec **téléchargements parallèles limités** (~3–5 simultanés). +- Statuts : en attente, en cours, en pause, terminé, échec, annulé. +- **Pause / reprise** (via requêtes de plage HTTP), **retry**, **annulation**, « tout annuler », nettoyage des tâches finies. +- **Progression temps réel** : pourcentage, vitesse, temps restant — rafraîchie dynamiquement côté interface. +- **Anti-doublons** : si un téléchargement de la même source est déjà actif/en attente, on retourne la tâche existante au lieu d'en créer une. +- **Persistance** : au démarrage, le dossier de téléchargements est scanné pour **restaurer** les tâches terminées. +- Noms de fichiers **nettoyés et sécurisés** automatiquement (suppression de caractères interdits, protection contre la traversée de répertoires). +- Les URLs circulent au format interne `video_url|page_url|titre` pour ne pas perdre le contexte entre l'extraction et le téléchargement. + +### 5. Watchlist + téléchargement automatique (le cœur de l'app) +- L'utilisateur suit un titre ; chaque item a un **statut** : actif, en pause, terminé, archivé. +- Mémorisation du **dernier épisode téléchargé** et du dernier épisode disponible. +- Un **planificateur interne** vérifie périodiquement (intervalle configurable, d'1 h à 168 h) tous les titres « dus » : + - détecte les nouveaux épisodes par comparaison au dernier connu, + - les **télécharge automatiquement**, + - journalise un rapport (nouveaux épisodes trouvés / téléchargés). +- Réglages globaux de la watchlist (activation de l'auto-download, intervalle, etc.), statistiques de suivi. +- Vérification manuelle possible à la demande (« vérifier maintenant »). + +### 6. Intégration Sonarr (homelab) +- Réception de **webhooks Sonarr** (événements type « épisode importé/téléchargé ») avec **vérification d'authenticité** (signature HMAC + secret) optionnelle. +- **Mapping entre séries Sonarr et sources de scraping** : pour chaque série suivie dans Sonarr, on associe le titre/URL correspondant sur un site d'animes, pour que Sonarr déclenche le téléchargement dans la bonne source. +- Endpoints d'aide : recherche Sonarr, recherche d'épisodes, **suggestions automatiques de mapping**, configuration (langue, qualité, provider par défaut, activation du webhook, journalisation). + +### 7. Favoris +- Sauvegarde de titres aimés avec leurs métadonnées et images, avec statistiques et pagination/navigation. + +### 8. Recommandations et découvertes +- **Analyse de l'historique de téléchargement** (parsing des noms de fichiers pour extraire titres, groupes, saisons, qualité) → profil de goûts (genres, titres) → **suggestions personnalisées**. +- Sections découverte : **dernières sorties**, **sorties saisonnières**, **calendrier des sorties**, **top** actuel. + +### 9. Comptes et administration +- Inscription/connexion/déconnexion avec **sessions par tokens** (token court + refresh token longue durée). +- Rôles **admin / utilisateur**, activation/désactivation de comptes. +- Interface d'**administration** : liste des utilisateurs, stats, bascule admin/actif, suppression. + +### 10. Paramètres et gestion des sources +- **Activation/désactivation de chaque source** individuellement (sans redémarrage), avec état de **santé** vérifiable (test manuel + contrôle automatique périodique par le planificateur). +- Réglages de l'interface utilisateur, configuration persistée. +- Endpoints publics de santé de l'application et liste des sources disponibles. + +## 🧩 Principes de conception pour la réécriture + +- **Architecture à 3 niveaux de scrapers** : sites de catalogues (animes / séries) → hébergeurs vidéo → extraction générique. Chaque niveau a une classe de base et un registre ; ajouter une nouvelle source = écrire un module qui implémente le contrat et le déclarer dans le registre. *Point le plus important : les sites changent souvent, il faut pouvoir en ajouter un en une heure.* +- **Configuration de scraping externalisée** : piloter au moins certaines sources par fichier de configuration (sélecteurs), pour réparer un site cassé sans toucher au code. +- **Un seul format interne** pour transporter le contexte (`vidéo|page|titre`) du scraping jusqu'au téléchargement. +- **Sécurité non négociable** : nettoyage systématique des noms de fichiers, secrets hors fichiers de config, tokens signés. +- **Une seule source de vérité** pour les données (pas de double stockage JSON + base). +- **Pas d'erreurs silencieuses** : chaque échec de scraping/téléchargement est journalisé et exposé (statut d'échec visible dans l'UI). +- **Injection de dépendances** entre les composants (vérificateur d'épisodes ↔ gestionnaire de téléchargements) pour éviter les dépendances circulaires. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6072a1a --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# ⛩ Ohm Stream Downloader + +Application web **auto-hébergée** (homelab) : centre de contrôle unique pour découvrir, +regarder et télécharger des animes et séries VOSTFR/VF. + +> MVP — Phase 1 : recherche multi-sources, fiches enrichies, streaming, gestionnaire +> de téléchargements temps réel, bibliothèque locale, favoris, administration. +> Phase 2 : intégration Sonarr/Prowlarr — OhmStreaming est un **indexeur Torznab** +> et personnalise « Pour toi » avec vos téléchargements Sonarr. + +## Démarrage rapide + +```bash +uv sync +# ffmpeg requis pour les flux HLS (binaire statique dans ~/.local/bin, ou apt install ffmpeg) +uv run uvicorn app.main:app --host 0.0.0.0 --port 8777 +``` + +Serveur persistant : `tmux new-session -d -s ohm 'cd ~/Développement/ohm_streaming && uv run uvicorn app.main:app --host 0.0.0.0 --port 8777'` + +Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**. + +## Configuration + +Variables d'environnement (préfixe `OHM_`, voir `.env.example`) : + +| Variable | Défaut | Rôle | +|---|---|---| +| `OHM_SECRET_KEY` | `change-me-in-production` | **À changer** — signature des tokens | +| `OHM_DOWNLOAD_DIR` | `./downloads` | Dossier des fichiers téléchargés | +| `OHM_DATABASE_PATH` | `./data/ohm.db` | Base SQLite | +| `OHM_MAX_PARALLEL_DOWNLOADS` | `3` | Téléchargements simultanés | + +## Fonctionnalités + +- **Recherche unifiée** sur plusieurs sources (Vostfree, French-Manga) — chaque source + est un module interchangeable activable/désactivable à chaud (page Admin). +- **Extraction en 2 niveaux** : page d'épisode → lecteurs embarqués → URL directe + (Sibnet, SendVid, VidMoly, Uqload, Vidzy, Luluvdo). +- **Proxy vidéo intégré** (`/api/proxy`) : contourne les protections (tokens liés à l'IP, Referer/UA obligatoires), réécrit les playlists HLS. +- **Streaming HLS** via hls.js ; **téléchargement HLS** via ffmpeg (remux mp4, progression temps réel). +- **Métadonnées enrichies** via Kitsu (synopsis, genres, note, images) avec cache 72 h. +- **Téléchargements** : file parallèle, pause/reprise (HTTP Range), retry, anti-doublons, + progression en temps réel (SSE), persistance au redémarrage. +- **Bibliothèque locale** : streaming avec range requests, reprise de lecture, + navigation épisode suivant/précédent. +- **Comptes** : JWT court + refresh token (rotation), rôles admin/utilisateur, + administration des comptes. +- **Découverte** (`/discover`) : 🆕 nouveautés fusionnées de toutes les sources, triées + par date de sortie réelle (enrichissement Kitsu, badge « en cours de diffusion »), + 🔥 incontournables (top popularité Kitsu) et ✨ recommandations par genres déduites + des téléchargements et favoris, titres déjà possédés exclus (cache mémoire). + + +## Intégration Sonarr / Prowlarr (*arr) + +OhmStreaming expose une **API Torznab** : la suite *arr le voit comme un indexeur +de plus, et chaque grab Sonarr déclenche l'extraction + le téléchargement dans +la file interne (les épisodes arrivent dans la bibliothèque OhmStreaming). + +### 1. OhmStreaming comme indexeur + +Dans **Admin → Intégrations Sonarr / Prowlarr**, copier : + +| Champ | Valeur | +|---|---| +| URL | `http://:8777/torznab/api` | +| Clé API | générée automatiquement (bouton Régénérer pour la changer) | + +- **Prowlarr** : Indexers → Add Indexer → *Generic Torznab* → coller URL + clé, + puis synchroniser vers Sonarr. +- **Sonarr** (direct) : Settings → Indexers → Add → *Torznab* → coller URL + clé. + Catégories : TV (5000) / Anime (5070). +- **Client de téléchargement** : « Torrent Blackhole » — Sonarr enregistre le + `.torrent` de service tandis qu'OhmStreaming télécharge réellement l'épisode + (extraction embed → HLS/HTTP → mp4 dans `downloads/`). + +Endpoints : `t=caps`, `t=tvsearch` (q, season, ep), `t=search` — auth par +`?apikey=` ou en-tête `X-Api-Key`. + +### 2. « Pour toi » personnalisé par Sonarr + +Toujours dans **Admin → Intégrations**, renseigner l'URL Sonarr +(`http://sonarr:8989`) et sa clé API (*Settings → General → API Key*), puis +« Enregistrer » et « Tester ». Les genres des séries téléchargées/grabées sur +Sonarr alimentent la section ✨ **Pour toi** (et ce qui y est possédé n'est pas +re-recommandé). Sonarr absent ou KO → dégradation gracieuse, rien ne casse. + +## Architecture + +``` +app/ +├── main.py # FastAPI + lifespan (DB, download manager) +├── config.py # pydantic-settings (OHM_*) +├── db.py # SQLite (aiosqlite) — source de vérité unique +├── routers/ # auth, search, discover, downloads, library, admin, torznab, pages +├── scrapers/ +│ ├── base.py # contrats SourceScraper/HosterExtractor + registres +│ ├── configs/ # sélecteurs YAML externalisés (réparer sans coder) +│ ├── sources/ # vostfree, french_manga +├── services/ # downloads, kitsu, discover, sonarr, torznab, settings +└── templates/ + static/ # UI htmx + Alpine.js, thème sombre +``` + +**Ajouter une source** : créer `app/scrapers/sources/ma_source.py` qui implémente +`SourceScraper`, la décorer `@register_source` — c'est tout (registre auto-découvert). + +## Tests + +```bash +uv run pytest # 61 tests +uv run ruff check . # lint +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..b4bb664 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,139 @@ +"""Authentification : JWT court + refresh token longue durée, rôles admin/user.""" + +import hashlib +import logging +import secrets +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +import jwt +from pwdlib import PasswordHash + +from app.config import get_settings +from app.db import db + +logger = logging.getLogger(__name__) + +password_hash = PasswordHash.recommended() + +ALGORITHM = "HS256" + + +@dataclass +class User: + id: int + username: str + is_admin: bool + is_active: bool + + +# ---------------------------------------------------------------- mots de passe + + +def hash_password(password: str) -> str: + return password_hash.hash(password) + + +def verify_password(password: str, hashed: str) -> bool: + return password_hash.verify(password, hashed) + + +# ---------------------------------------------------------------- utilisateurs + + +async def create_user(username: str, password: str) -> User: + """Crée un compte ; le tout premier utilisateur devient admin.""" + row = await db.fetchone("SELECT COUNT(*) AS n FROM users") + is_first = row["n"] == 0 + cursor = await db.execute( + "INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)", + (username.strip(), hash_password(password), int(is_first)), + ) + user = User(id=cursor.lastrowid, username=username.strip(), is_admin=is_first, is_active=True) + logger.info("Utilisateur créé : %s (admin=%s)", user.username, user.is_admin) + return user + + +async def authenticate(username: str, password: str) -> User | None: + row = await db.fetchone( + "SELECT id, username, password_hash, is_admin, is_active FROM users WHERE username = ?", + (username.strip(),), + ) + if row is None or not verify_password(password, row["password_hash"]): + logger.warning("Échec d'authentification pour %r", username) + return None + if not row["is_active"]: + logger.warning("Compte désactivé : %r", username) + return None + return User( + id=row["id"], username=row["username"], is_admin=bool(row["is_admin"]), is_active=True + ) + + +async def get_user(user_id: int) -> User | None: + row = await db.fetchone( + "SELECT id, username, is_admin, is_active FROM users WHERE id = ?", (user_id,) + ) + if row is None or not row["is_active"]: + return None + return User( + id=row["id"], username=row["username"], is_admin=bool(row["is_admin"]), is_active=True + ) + + +# ---------------------------------------------------------------- tokens + + +def create_access_token(user: User) -> str: + settings = get_settings() + payload = { + "sub": str(user.id), + "username": user.username, + "admin": user.is_admin, + "exp": datetime.now(UTC) + timedelta(minutes=settings.access_token_ttl_minutes), + } + return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM) + + +def decode_access_token(token: str) -> dict | None: + try: + return jwt.decode(token, get_settings().secret_key, algorithms=[ALGORITHM]) + except jwt.PyJWTError as exc: + logger.debug("Token d'accès invalide : %s", exc) + return None + + +def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +async def create_refresh_token(user_id: int) -> str: + """Refresh token opaque ; seul son hash est stocké en DB (révocable).""" + token = secrets.token_urlsafe(48) + expires = datetime.now(UTC) + timedelta(days=get_settings().refresh_token_ttl_days) + await db.execute( + "INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)", + (user_id, _hash_token(token), expires.strftime("%Y-%m-%d %H:%M:%S")), + ) + return token + + +async def use_refresh_token(token: str) -> User | None: + """Valide un refresh token, le révoque (rotation) et retourne l'utilisateur.""" + row = await db.fetchone( + "SELECT id, user_id FROM refresh_tokens " + "WHERE token_hash = ? AND revoked = 0 AND expires_at > datetime('now')", + (_hash_token(token),), + ) + if row is None: + logger.warning("Refresh token invalide ou expiré") + return None + await db.execute("UPDATE refresh_tokens SET revoked = 1 WHERE id = ?", (row["id"],)) + user = await get_user(row["user_id"]) + if user is None: + logger.warning("Refresh token d'un compte supprimé/désactivé (user_id=%s)", row["user_id"]) + return user + + +async def revoke_all_refresh_tokens(user_id: int) -> None: + await db.execute("UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?", (user_id,)) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..7dd5f67 --- /dev/null +++ b/app/config.py @@ -0,0 +1,46 @@ +from functools import lru_cache +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + +BASE_DIR = Path(__file__).resolve().parent.parent + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="OHM_", env_file=".env", extra="ignore") + + app_name: str = "Ohm Stream Downloader" + debug: bool = False + + # Données + data_dir: Path = BASE_DIR / "data" + download_dir: Path = BASE_DIR / "downloads" + database_path: Path = BASE_DIR / "data" / "ohm.db" + + # Sécurité — à surcharger via OHM_SECRET_KEY en production + secret_key: str = "change-me-in-production" + access_token_ttl_minutes: int = 15 + refresh_token_ttl_days: int = 30 + + # Scraping + http_timeout: float = 20.0 + user_agent: str = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0" + scrapers_config_dir: Path = BASE_DIR / "app" / "scrapers" / "configs" + + # Téléchargements + max_parallel_downloads: int = 3 + + # Kitsu + kitsu_base_url: str = "https://kitsu.io/api/edge" + metadata_cache_ttl_hours: int = 72 + + def ensure_dirs(self) -> None: + self.data_dir.mkdir(parents=True, exist_ok=True) + self.download_dir.mkdir(parents=True, exist_ok=True) + + +@lru_cache +def get_settings() -> Settings: + settings = Settings() + settings.ensure_dirs() + return settings diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..c86dd49 --- /dev/null +++ b/app/db.py @@ -0,0 +1,123 @@ +"""Couche d'accès SQLite (aiosqlite) — unique source de vérité de l'application.""" + +import aiosqlite + +from app.config import get_settings + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS downloads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_key TEXT NOT NULL, -- clé de déduplication (video_url normalisée) + video_url TEXT NOT NULL, + page_url TEXT, + title TEXT NOT NULL, + file_path TEXT, -- relatif au dossier de téléchargement + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','downloading','paused','done','failed','cancelled')), + total_bytes INTEGER, + downloaded_bytes INTEGER NOT NULL DEFAULT 0, + error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status); +CREATE UNIQUE INDEX IF NOT EXISTS idx_downloads_active_source + ON downloads(source_key) WHERE status IN ('pending','downloading','paused'); + +CREATE TABLE IF NOT EXISTS metadata_cache ( + cache_key TEXT PRIMARY KEY, + payload TEXT NOT NULL, -- JSON + fetched_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL -- JSON +); + +CREATE TABLE IF NOT EXISTS favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + source TEXT NOT NULL, + source_id TEXT NOT NULL, + title TEXT NOT NULL, + image_url TEXT, + payload TEXT, -- JSON métadonnées + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (user_id, source, source_id) +); + +CREATE TABLE IF NOT EXISTS watch_progress ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE, + position_seconds REAL NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (user_id, download_id) +); +""" + + +class Database: + """Connexion SQLite partagée, initialisée au démarrage de l'app.""" + + def __init__(self) -> None: + self._conn: aiosqlite.Connection | None = None + + async def connect(self) -> None: + path = get_settings().database_path + self._conn = await aiosqlite.connect(path) + self._conn.row_factory = aiosqlite.Row + await self._conn.execute("PRAGMA journal_mode=WAL") + await self._conn.execute("PRAGMA foreign_keys=ON") + await self._conn.executescript(SCHEMA) + await self._conn.commit() + + async def close(self) -> None: + if self._conn is not None: + await self._conn.close() + self._conn = None + + @property + def conn(self) -> aiosqlite.Connection: + if self._conn is None: + raise RuntimeError("Database.connect() n'a pas été appelé") + return self._conn + + async def execute(self, sql: str, params: tuple = ()) -> aiosqlite.Cursor: + cursor = await self.conn.execute(sql, params) + await self.conn.commit() + return cursor + + async def fetchone(self, sql: str, params: tuple = ()) -> aiosqlite.Row | None: + cursor = await self.conn.execute(sql, params) + row = await cursor.fetchone() + await cursor.close() + return row + + async def fetchall(self, sql: str, params: tuple = ()) -> list[aiosqlite.Row]: + cursor = await self.conn.execute(sql, params) + rows = await cursor.fetchall() + await cursor.close() + return rows + + +db = Database() diff --git a/app/logging_config.py b/app/logging_config.py new file mode 100644 index 0000000..fe3445b --- /dev/null +++ b/app/logging_config.py @@ -0,0 +1,15 @@ +import logging +import sys + + +def setup_logging(debug: bool = False) -> None: + level = logging.DEBUG if debug else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s | %(levelname)-7s | %(name)s | %(message)s", + stream=sys.stdout, + force=True, + ) + # Réduit le bruit des libs tierces + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..83dec24 --- /dev/null +++ b/app/main.py @@ -0,0 +1,69 @@ +import asyncio +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from app.config import BASE_DIR, get_settings +from app.db import db +from app.logging_config import setup_logging +from app.routers import admin, auth, discover, downloads, library, pages, proxy, search, torznab +from app.scrapers.http import close_client +from app.services.discover import discover as discover_service +from app.services.downloads import download_manager + +logger = logging.getLogger(__name__) + + +def _log_task_error(task: asyncio.Task) -> None: + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + logger.warning("Réchauffe découverte échouée : %s", exc) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + settings = get_settings() + setup_logging(settings.debug) + await db.connect() + await download_manager.start() + warmup = asyncio.create_task(discover_service.latest()) + warmup.add_done_callback(_log_task_error) + logger.info("%s prêt", settings.app_name) + yield + warmup.cancel() + await download_manager.stop() + await close_client() + await db.close() + + +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI(title=settings.app_name, lifespan=lifespan) + app.include_router(torznab.router) + + app.include_router(pages.router) + app.include_router(pages.protected) + + app.include_router(auth.router) + app.include_router(search.router) + app.include_router(discover.router) + app.include_router(downloads.router) + app.include_router(library.router) + app.include_router(admin.router) + app.include_router(proxy.router) + + app.mount("/static", StaticFiles(directory=BASE_DIR / "app" / "static"), name="static") + + @app.get("/health") + async def health() -> dict[str, str]: + return {"status": "ok"} + + return app + + +app = create_app() diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/admin.py b/app/routers/admin.py new file mode 100644 index 0000000..868a6dd --- /dev/null +++ b/app/routers/admin.py @@ -0,0 +1,168 @@ +"""Administration : utilisateurs, activation des sources, santé.""" + +import asyncio +import logging + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from app import auth +from app.db import db +from app.routers.auth import AdminUser +from app.scrapers.base import ScrapeError, all_sources, get_source, import_all_scrapers +from app.services.discover import discover +from app.services.settings import ( + get_sonarr_config, + get_torznab_apikey, + is_source_enabled, + reset_torznab_apikey, + set_sonarr_config, + set_source_enabled, +) +from app.services.sonarr import sonarr + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + +import_all_scrapers() + + +# ---------------------------------------------------------------- utilisateurs + + +@router.get("/users") +async def list_users(admin: AdminUser) -> list[dict]: + rows = await db.fetchall( + "SELECT id, username, is_admin, is_active, created_at FROM users ORDER BY id" + ) + return [dict(row) for row in rows] + + +@router.post("/users/{user_id}/toggle-active") +async def toggle_active(user_id: int, admin: AdminUser) -> dict: + if user_id == admin.id: + raise HTTPException(400, "Impossible de désactiver son propre compte") + row = await db.fetchone("SELECT is_active FROM users WHERE id = ?", (user_id,)) + if row is None: + raise HTTPException(404, "Utilisateur introuvable") + new_state = 0 if row["is_active"] else 1 + await db.execute("UPDATE users SET is_active = ? WHERE id = ?", (new_state, user_id)) + if not new_state: + await auth.revoke_all_refresh_tokens(user_id) + return {"is_active": bool(new_state)} + + +@router.post("/users/{user_id}/toggle-admin") +async def toggle_admin(user_id: int, admin: AdminUser) -> dict: + if user_id == admin.id: + raise HTTPException(400, "Impossible de modifier ses propres droits") + row = await db.fetchone("SELECT is_admin FROM users WHERE id = ?", (user_id,)) + if row is None: + raise HTTPException(404, "Utilisateur introuvable") + new_state = 0 if row["is_admin"] else 1 + await db.execute("UPDATE users SET is_admin = ? WHERE id = ?", (new_state, user_id)) + return {"is_admin": bool(new_state)} + + +@router.delete("/users/{user_id}") +async def delete_user(user_id: int, admin: AdminUser) -> dict: + if user_id == admin.id: + raise HTTPException(400, "Impossible de supprimer son propre compte") + cursor = await db.execute("DELETE FROM users WHERE id = ?", (user_id,)) + if cursor.rowcount == 0: + raise HTTPException(404, "Utilisateur introuvable") + return {"ok": True} + + +@router.get("/stats") +async def stats(admin: AdminUser) -> dict: + users = await db.fetchone("SELECT COUNT(*) AS n FROM users") + downloads = await db.fetchall("SELECT status, COUNT(*) AS n FROM downloads GROUP BY status") + return { + "users": users["n"], + "downloads": {row["status"]: row["n"] for row in downloads}, + } + + +# ---------------------------------------------------------------- sources + + +class SourceToggle(BaseModel): + enabled: bool + + +@router.post("/sources/{name}/toggle") +async def toggle_source(name: str, payload: SourceToggle, admin: AdminUser) -> dict: + get_source(name) # 404 implicite si inconnue + await set_source_enabled(name, payload.enabled) + return {"name": name, "enabled": payload.enabled} + + +@router.post("/sources/{name}/health") +async def health_check(name: str, admin: AdminUser) -> dict: + """Test de santé manuel : la source doit répondre à une recherche simple.""" + source = get_source(name) + try: + results = await asyncio.wait_for(source.search("naruto"), timeout=30) + healthy = len(results) > 0 + detail = f"{len(results)} résultats" + except (ScrapeError, TimeoutError) as exc: + healthy = False + detail = str(exc)[:200] + logger.error("Health check %s KO : %s", name, exc) + return {"name": name, "healthy": healthy, "detail": detail} + + +@router.get("/sources") +async def sources_status(admin: AdminUser) -> list[dict]: + return [ + { + "name": s.name, + "label": s.label, + "base_url": s.base_url, + "enabled": await is_source_enabled(s.name), + } + for s in all_sources() + ] + +# ---------------------------------------------------------------- intégrations *arr + + +class SonarrConfig(BaseModel): + url: str + apikey: str + + +@router.get("/integrations") +async def integrations(admin: AdminUser, request: Request) -> dict: + """Configuration Torznab (indexeur) et Sonarr (recommandations).""" + config = await get_sonarr_config() + base = str(request.base_url).rstrip("/") + return { + "torznab": { + "apikey": await get_torznab_apikey(), + "endpoint": f"{base}/torznab/api", + }, + "sonarr": config, + } + + +@router.post("/integrations/torznab/regenerate") +async def regenerate_torznab_key(admin: AdminUser) -> dict: + key = await reset_torznab_apikey() + return {"apikey": key} + + +@router.put("/integrations/sonarr") +async def save_sonarr(payload: SonarrConfig, admin: AdminUser) -> dict: + """Enregistre la connexion Sonarr et recalcule les recommandations.""" + await set_sonarr_config(payload.url, payload.apikey) + sonarr.invalidate() + discover.invalidate_for_you() + return {"ok": True} + + +@router.post("/integrations/sonarr/test") +async def test_sonarr(admin: AdminUser) -> dict: + return await sonarr.test_connection() diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..a601205 --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,110 @@ +"""Routes d'authentification (cookies httponly, adaptées à l'UI htmx).""" + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status +from fastapi.responses import RedirectResponse + +from app import auth +from app.auth import User + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/auth", tags=["auth"]) + +ACCESS_COOKIE = "ohm_access" +REFRESH_COOKIE = "ohm_refresh" + + +def set_auth_cookies(response: Response, user: User, refresh_token: str) -> None: + response.set_cookie( + ACCESS_COOKIE, auth.create_access_token(user), httponly=True, samesite="lax" + ) + response.set_cookie(REFRESH_COOKIE, refresh_token, httponly=True, samesite="lax", path="/") + + +def clear_auth_cookies(response: Response) -> None: + response.delete_cookie(ACCESS_COOKIE) + response.delete_cookie(REFRESH_COOKIE, path="/") + + +# ---------------------------------------------------------------- dépendances + + +async def current_user(request: Request, response: Response) -> User: + """Utilisateur courant via cookie d'accès ; tente un refresh si expiré.""" + token = request.cookies.get(ACCESS_COOKIE) + if token: + payload = auth.decode_access_token(token) + if payload: + user = await auth.get_user(int(payload["sub"])) + if user: + return user + + refresh = request.cookies.get(REFRESH_COOKIE) + if refresh: + user = await auth.use_refresh_token(refresh) + if user: + new_refresh = await auth.create_refresh_token(user.id) + set_auth_cookies(response, user, new_refresh) + return user + + raise HTTPException(status.HTTP_303_SEE_OTHER, headers={"Location": "/login"}) + + +async def require_admin(user: Annotated[User, Depends(current_user)]) -> User: + if not user.is_admin: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Droits administrateur requis") + return user + + +CurrentUser = Annotated[User, Depends(current_user)] +AdminUser = Annotated[User, Depends(require_admin)] + + +# ---------------------------------------------------------------- routes + + +@router.post("/register") +async def register( + response: Response, + username: Annotated[str, Form(min_length=3, max_length=32)], + password: Annotated[str, Form(min_length=6)], +) -> RedirectResponse: + row = await auth.db.fetchone("SELECT id FROM users WHERE username = ?", (username.strip(),)) + if row is not None: + raise HTTPException(status.HTTP_409_CONFLICT, detail="Nom d'utilisateur déjà pris") + user = await auth.create_user(username, password) + refresh = await auth.create_refresh_token(user.id) + redirect = RedirectResponse("/", status.HTTP_303_SEE_OTHER) + set_auth_cookies(redirect, user, refresh) + return redirect + + +@router.post("/login") +async def login( + response: Response, + username: Annotated[str, Form()], + password: Annotated[str, Form()], +) -> RedirectResponse: + user = await auth.authenticate(username, password) + if user is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Identifiants invalides") + refresh = await auth.create_refresh_token(user.id) + redirect = RedirectResponse("/", status.HTTP_303_SEE_OTHER) + set_auth_cookies(redirect, user, refresh) + return redirect + + +@router.post("/logout") +async def logout(user: CurrentUser) -> RedirectResponse: + await auth.revoke_all_refresh_tokens(user.id) + redirect = RedirectResponse("/login", status.HTTP_303_SEE_OTHER) + clear_auth_cookies(redirect) + return redirect + + +@router.get("/me") +async def me(user: CurrentUser) -> dict: + return {"id": user.id, "username": user.username, "is_admin": user.is_admin} diff --git a/app/routers/discover.py b/app/routers/discover.py new file mode 100644 index 0000000..6bd4964 --- /dev/null +++ b/app/routers/discover.py @@ -0,0 +1,27 @@ +"""Découverte : nouveautés des sources, incontournables, recommandations.""" + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, Query + +from app.routers.auth import CurrentUser, current_user +from app.services.discover import discover + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["discover"], dependencies=[Depends(current_user)]) + + +@router.get("/discover") +async def get_discover( + user: CurrentUser, + latest_limit: Annotated[int, Query(ge=1, le=50)] = 24, + must_watch_limit: Annotated[int, Query(ge=1, le=20)] = 20, + for_you_limit: Annotated[int, Query(ge=1, le=20)] = 20, +) -> dict: + """Les trois sections de découverte en une requête (sections vides si source KO).""" + must_watch = await discover.must_watch(must_watch_limit) + for_you = await discover.for_you(user.id, for_you_limit) + latest = await discover.latest(latest_limit) + return {"latest": latest, "must_watch": must_watch, "for_you": for_you} diff --git a/app/routers/downloads.py b/app/routers/downloads.py new file mode 100644 index 0000000..34111e0 --- /dev/null +++ b/app/routers/downloads.py @@ -0,0 +1,87 @@ +"""Gestion de la file de téléchargements + progression temps réel (SSE).""" + +import json +import logging +from collections.abc import AsyncIterator + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sse_starlette.sse import EventSourceResponse + +from app.routers.auth import current_user +from app.scrapers.base import decode_internal_url +from app.services.downloads import download_manager + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/api/downloads", tags=["downloads"], dependencies=[Depends(current_user)] +) + + +class EnqueueRequest(BaseModel): + internal_url: str # format `video_url|page_url|titre` + + +@router.get("") +async def list_downloads() -> list[dict]: + return await download_manager.list_all() + + +@router.post("", status_code=201) +async def enqueue(payload: EnqueueRequest) -> dict: + try: + video_url, page_url, title = decode_internal_url(payload.internal_url) + except ValueError as exc: + raise HTTPException(422, detail=str(exc)) from exc + return await download_manager.enqueue(video_url, page_url, title) + + +@router.post("/{download_id}/pause") +async def pause(download_id: int) -> dict: + await download_manager.pause(download_id) + return {"ok": True} + + +@router.post("/{download_id}/resume") +async def resume(download_id: int) -> dict: + if not await download_manager.resume(download_id): + raise HTTPException(409, detail="Ce téléchargement n'est pas en pause") + return {"ok": True} + + +@router.post("/{download_id}/retry") +async def retry(download_id: int) -> dict: + if not await download_manager.retry(download_id): + raise HTTPException( + 409, detail="Seules les tâches en échec/annulées peuvent être relancées" + ) + return {"ok": True} + + +@router.post("/{download_id}/cancel") +async def cancel(download_id: int) -> dict: + await download_manager.cancel(download_id) + return {"ok": True} + + +@router.post("/cancel-all") +async def cancel_all() -> dict: + return {"cancelled": await download_manager.cancel_all()} + + +@router.post("/clear-finished") +async def clear_finished() -> dict: + return {"removed": await download_manager.clear_finished()} + + +@router.get("/events") +async def events() -> EventSourceResponse: + """Flux SSE : progression de tous les téléchargements en temps réel.""" + + async def stream() -> AsyncIterator[dict]: + yield {"data": json.dumps({"type": "snapshot", "items": await download_manager.list_all()})} + async for update in download_manager.subscribe(): + yield {"data": json.dumps({"type": "update", "item": update}, default=str)} + + return EventSourceResponse(stream()) diff --git a/app/routers/library.py b/app/routers/library.py new file mode 100644 index 0000000..a947257 --- /dev/null +++ b/app/routers/library.py @@ -0,0 +1,201 @@ +"""Bibliothèque locale, streaming de fichiers (range requests) et favoris.""" + +import logging +import mimetypes +import re +from collections.abc import AsyncIterator + +import aiosqlite +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from app.config import get_settings +from app.db import db +from app.routers.auth import CurrentUser, current_user +from app.services.downloads import download_manager + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["library"], dependencies=[Depends(current_user)]) + +CHUNK_SIZE = 1 << 20 # 1 Mio + + +# ---------------------------------------------------------------- bibliothèque + + +@router.get("/library") +async def library(user: CurrentUser) -> list[dict]: + """Fichiers téléchargés, streamables, avec progression de visionnage.""" + rows = await db.fetchall( + "SELECT d.*, wp.position_seconds FROM downloads d " + "LEFT JOIN watch_progress wp ON wp.download_id = d.id AND wp.user_id = ? " + "WHERE d.status = 'done' ORDER BY d.updated_at DESC", + (user.id,), + ) + return [dict(row) for row in rows] + + +@router.get("/library/{download_id}/neighbors") +async def neighbors(download_id: int) -> dict: + """Épisode précédent/suivant : heuristique sur les titres (même série, N±1).""" + rows = await db.fetchall("SELECT id, title FROM downloads WHERE status = 'done' ORDER BY title") + + def parse(title: str) -> tuple[str, float | None]: + match = re.search(r"(?:épisode|episode|ep|e)\s*(\d+(?:\.\d+)?)", title, re.IGNORECASE) + if not match: + return title, None + return title[: match.start()].strip(), float(match.group(1)) + + current = await db.fetchone("SELECT id, title FROM downloads WHERE id = ?", (download_id,)) + if current is None: + raise HTTPException(404, "Fichier introuvable") + base, number = parse(current["title"]) + prev_ep = next_ep = None + for row in rows: + other_base, other_num = parse(row["title"]) + if other_base != base or other_num is None or row["id"] == download_id: + continue + if number is not None and other_num == number - 1: + prev_ep = row["id"] + if number is not None and other_num == number + 1: + next_ep = row["id"] + return {"previous": prev_ep, "next": next_ep} + + +# ---------------------------------------------------------------- streaming + +_RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)") + + +@router.get("/stream/{download_id}") +async def stream(download_id: int, request: Request) -> StreamingResponse: + """Streaming d'un fichier local avec support des requêtes Range (206).""" + row = await db.fetchone( + "SELECT file_path FROM downloads WHERE id = ? AND status = 'done'", (download_id,) + ) + if row is None: + raise HTTPException(404, "Fichier introuvable ou téléchargement incomplet") + path = get_settings().download_dir / row["file_path"] + if not path.is_file(): + logger.error("Fichier manquant sur disque : %s", path) + raise HTTPException(404, "Fichier absent du disque") + + size = path.stat().st_size + content_type = mimetypes.guess_type(path.name)[0] or "video/mp4" + start, end = 0, size - 1 + status_code = 200 + + range_header = request.headers.get("range") + if range_header: + match = _RANGE_RE.fullmatch(range_header) + if match: + if match.group(1): + start = int(match.group(1)) + if match.group(2): + end = min(int(match.group(2)), size - 1) + if start >= size: + raise HTTPException(416, "Plage invalide") + status_code = 206 + + length = end - start + 1 + + async def iter_file() -> AsyncIterator[bytes]: + with path.open("rb") as fh: + fh.seek(start) + remaining = length + while remaining > 0: + chunk = fh.read(min(CHUNK_SIZE, remaining)) + if not chunk: + break + remaining -= len(chunk) + yield chunk + + headers = { + "Content-Range": f"bytes {start}-{end}/{size}", + "Accept-Ranges": "bytes", + "Content-Length": str(length), + } + return StreamingResponse( + iter_file(), status_code=status_code, headers=headers, media_type=content_type + ) + + +class ProgressRequest(BaseModel): + position_seconds: float = Field(ge=0) + + +@router.post("/stream/{download_id}/progress") +async def save_progress(download_id: int, payload: ProgressRequest, user: CurrentUser) -> dict: + """Reprise de lecture : mémorise la position de visionnage.""" + await db.execute( + "INSERT INTO watch_progress (user_id, download_id, position_seconds, updated_at) " + "VALUES (?, ?, ?, datetime('now')) " + "ON CONFLICT(user_id, download_id) DO UPDATE SET " + "position_seconds = excluded.position_seconds, updated_at = excluded.updated_at", + (user.id, download_id, payload.position_seconds), + ) + return {"ok": True} + + +# ---------------------------------------------------------------- favoris + + +class FavoriteRequest(BaseModel): + source: str + source_id: str + title: str + image_url: str | None = None + payload: dict | None = None + + +@router.get("/favorites") +async def list_favorites(user: CurrentUser, offset: int = 0, limit: int = 24) -> dict: + total = await db.fetchone("SELECT COUNT(*) AS n FROM favorites WHERE user_id = ?", (user.id,)) + rows = await db.fetchall( + "SELECT * FROM favorites WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?", + (user.id, limit, offset), + ) + return {"total": total["n"], "items": [dict(row) for row in rows]} + + +@router.post("/favorites", status_code=201) +async def add_favorite(payload: FavoriteRequest, user: CurrentUser) -> dict: + import json + + try: + await db.execute( + "INSERT INTO favorites (user_id, source, source_id, title, image_url, payload) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + user.id, + payload.source, + payload.source_id, + payload.title, + payload.image_url, + json.dumps(payload.payload, ensure_ascii=False) if payload.payload else None, + ), + ) + except aiosqlite.IntegrityError as exc: + raise HTTPException(409, "Déjà dans les favoris") from exc + return {"ok": True} + + +@router.delete("/favorites/{favorite_id}") +async def remove_favorite(favorite_id: int, user: CurrentUser) -> dict: + cursor = await db.execute( + "DELETE FROM favorites WHERE id = ? AND user_id = ?", (favorite_id, user.id) + ) + if cursor.rowcount == 0: + raise HTTPException(404, "Favori introuvable") + return {"ok": True} + + +# raccourci pratique pour l'UI +@router.get("/downloads/{download_id}") +async def get_download(download_id: int) -> dict: + data = await download_manager.get(download_id) + if data is None: + raise HTTPException(404, "Téléchargement introuvable") + return data diff --git a/app/routers/pages.py b/app/routers/pages.py new file mode 100644 index 0000000..d542b39 --- /dev/null +++ b/app/routers/pages.py @@ -0,0 +1,60 @@ +"""Pages HTML (Jinja2 + htmx). Toutes protégées sauf /login.""" + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from app.config import BASE_DIR +from app.routers.auth import current_user + +router = APIRouter(tags=["pages"], include_in_schema=False) +protected = APIRouter(tags=["pages"], include_in_schema=False, dependencies=[Depends(current_user)]) + +templates = Jinja2Templates(directory=BASE_DIR / "app" / "templates") + + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request) -> HTMLResponse: + return templates.TemplateResponse(request, "login.html") + + +@protected.get("/", response_class=HTMLResponse) +async def index(request: Request) -> HTMLResponse: + return templates.TemplateResponse(request, "index.html") + + +@protected.get("/discover", response_class=HTMLResponse) +async def discover_page(request: Request) -> HTMLResponse: + return templates.TemplateResponse(request, "discover.html") + + +@protected.get("/title/{source}/{source_id:path}", response_class=HTMLResponse) +async def title_page(request: Request, source: str, source_id: str) -> HTMLResponse: + return templates.TemplateResponse( + request, "title.html", {"source": source, "source_id": source_id} + ) + + +@protected.get("/downloads", response_class=HTMLResponse) +async def downloads_page(request: Request) -> HTMLResponse: + return templates.TemplateResponse(request, "downloads.html") + + +@protected.get("/library", response_class=HTMLResponse) +async def library_page(request: Request) -> HTMLResponse: + return templates.TemplateResponse(request, "library.html") + + +@protected.get("/watch/{download_id}", response_class=HTMLResponse) +async def watch_page(request: Request, download_id: int) -> HTMLResponse: + return templates.TemplateResponse(request, "watch.html", {"download_id": download_id}) + + +@protected.get("/favorites", response_class=HTMLResponse) +async def favorites_page(request: Request) -> HTMLResponse: + return templates.TemplateResponse(request, "favorites.html") + + +@protected.get("/admin", response_class=HTMLResponse) +async def admin_page(request: Request) -> HTMLResponse: + return templates.TemplateResponse(request, "admin.html") diff --git a/app/routers/proxy.py b/app/routers/proxy.py new file mode 100644 index 0000000..a48c66e --- /dev/null +++ b/app/routers/proxy.py @@ -0,0 +1,129 @@ +"""Proxy vidéo : sert les flux distants (mp4/HLS) via le serveur. + +Indispensable pour les hébergeurs dont les tokens sont liés à l'IP qui a +résolu le lien (le navigateur de l'utilisateur a une IP différente du serveur). +Les playlists m3u8 sont réécrites pour que segments et variantes passent aussi +par le proxy. Accès réservé aux utilisateurs connectés. +""" + +import logging +import re +from collections.abc import AsyncIterator +from urllib.parse import quote, urljoin + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import Response, StreamingResponse + +from app.routers.auth import current_user +from app.scrapers.http import get_client + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["proxy"], dependencies=[Depends(current_user)]) + +HLS_CONTENT_TYPES = ("application/vnd.apple.mpegurl", "application/x-mpegurl", "audio/mpegurl") +HOP_HEADERS = ( + "content-type", "content-length", "content-range", "accept-ranges", "cache-control" +) + + +def _check_url(url: str) -> None: + if not url.startswith(("http://", "https://")): + raise HTTPException(422, "URL invalide") + + +def proxy_url(remote_url: str, referer: str | None = None) -> str: + out = "/api/proxy?url=" + quote(remote_url, safe="") + if referer: + out += "&ref=" + quote(referer, safe="") + return out + + +@router.get("/proxy") +async def proxy( + request: Request, + url: str = Query(), + ref: str | None = Query(None), +) -> Response: + _check_url(url) + headers: dict[str, str] = {} + if ref: + _check_url(ref) + headers["Referer"] = ref + if range_header := request.headers.get("range"): + headers["Range"] = range_header + + client = get_client() + try: + upstream = await client.send( + client.build_request("GET", url, headers=headers), stream=True + ) + except httpx.HTTPError as exc: + logger.error("Proxy : échec de connexion à %s : %s", url, exc) + raise HTTPException(502, f"Hébergeur injoignable : {exc}") from exc + + content_type = upstream.headers.get("content-type", "") + is_hls = any(t in content_type for t in HLS_CONTENT_TYPES) or url.endswith(".m3u8") + + if is_hls: + # Playlist : lecture intégrale puis réécriture des URIs via le proxy + try: + body = (await upstream.aread()).decode("utf-8", errors="replace") + finally: + await upstream.aclose() + if upstream.status_code >= 400: + logger.error("Proxy HLS : %s → HTTP %s", url, upstream.status_code) + raise HTTPException(502, f"Hébergeur : HTTP {upstream.status_code}") + rewritten = _rewrite_playlist(body, url, ref) + return Response( + rewritten, + media_type="application/vnd.apple.mpegurl", + headers={"Cache-Control": "no-store"}, + ) + + if upstream.status_code >= 400: + await upstream.aclose() + logger.error("Proxy : %s → HTTP %s", url, upstream.status_code) + raise HTTPException(502, f"Hébergeur : HTTP {upstream.status_code}") + + async def byte_stream() -> AsyncIterator[bytes]: + try: + async for chunk in upstream.aiter_bytes(1 << 16): + yield chunk + finally: + await upstream.aclose() + + passthrough = { + h: upstream.headers[h] for h in HOP_HEADERS if h in upstream.headers + } + passthrough.setdefault("accept-ranges", "bytes") + return StreamingResponse( + byte_stream(), + status_code=upstream.status_code, + headers=passthrough, + media_type=content_type.split(";")[0] or "application/octet-stream", + ) + + +_KEY_URI_RE = re.compile(r'URI="([^"]+)"') + + +def _rewrite_playlist(body: str, base_url: str, referer: str | None) -> str: + """Réécrit toutes les URIs d'une playlist m3u8 pour passer par le proxy.""" + + def absolutize(uri: str) -> str: + return proxy_url(urljoin(base_url, uri), referer) + + lines = [] + for line in body.splitlines(): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("#"): + lines.append(_KEY_URI_RE.sub( + lambda m: f'URI="{absolutize(m.group(1))}"', line + )) + else: + lines.append(absolutize(stripped)) + return "\n".join(lines) + "\n" diff --git a/app/routers/search.py b/app/routers/search.py new file mode 100644 index 0000000..e449450 --- /dev/null +++ b/app/routers/search.py @@ -0,0 +1,131 @@ +"""Recherche multi-sources, fiches de titres et extraction de liens vidéo.""" + +import asyncio +import dataclasses +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.routers.auth import current_user +from app.scrapers.base import ( + ScrapeError, + SourceScraper, + VideoLink, + all_sources, + get_source, + import_all_scrapers, + resolve_hoster, +) +from app.services.kitsu import KitsuService +from app.services.settings import is_source_enabled + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["search"], dependencies=[Depends(current_user)]) + +import_all_scrapers() +kitsu = KitsuService() + + +async def enabled_sources() -> list[SourceScraper]: + sources = [] + for source in all_sources(): + if await is_source_enabled(source.name): + sources.append(source) + return sources + + +@router.get("/sources") +async def list_sources() -> list[dict]: + """Sources disponibles avec leur état d'activation.""" + return [ + { + "name": source.name, + "label": source.label, + "base_url": source.base_url, + "media_types": list(source.media_types), + "enabled": await is_source_enabled(source.name), + } + for source in all_sources() + ] + + +@router.get("/search") +async def search(q: Annotated[str, Query(min_length=2)]) -> dict: + """Recherche unifiée : une requête interroge toutes les sources activées.""" + sources = await enabled_sources() + + async def safe_search(source: SourceScraper) -> tuple[list, str | None]: + try: + results = [dataclasses.asdict(r) for r in await source.search(q)] + return results, None + except ScrapeError as exc: + logger.error("Recherche échouée sur %s : %s", source.name, exc) + return [], source.name + + outcomes = await asyncio.gather(*(safe_search(s) for s in sources)) + results = [item for items, _ in outcomes for item in items] + failed = [name for _, name in outcomes if name] + return {"query": q, "count": len(results), "results": results, "failed_sources": failed} + + +@router.get("/titles/{source}/{source_id:path}") +async def title_details(source: str, source_id: str, enrich: bool = True) -> dict: + """Fiche détaillée d'un titre (+ enrichissement Kitsu des champs manquants).""" + scraper = get_source(source) + try: + details = await scraper.get_details(source_id) + except ScrapeError as exc: + raise HTTPException(502, detail=str(exc)) from exc + if enrich: + details = await kitsu.enrich(details) + return dataclasses.asdict(details) + + +@router.get("/episodes/{source}/{source_id:path}") +async def list_episodes(source: str, source_id: str) -> dict: + scraper = get_source(source) + try: + episodes = await scraper.list_episodes(source_id) + except ScrapeError as exc: + raise HTTPException(502, detail=str(exc)) from exc + return {"episodes": [dataclasses.asdict(e) for e in episodes]} + + +@router.get("/extract") +async def extract(episode_url: Annotated[str, Query()]) -> dict: + """Résout la chaîne complète : page d'épisode → embeds → URL vidéo directe.""" + try: + source = _find_source_for_url(episode_url) + embeds = await source.extract_embed_links(episode_url) + except ScrapeError as exc: + raise HTTPException(502, detail=str(exc)) from exc + + links: list[dict] = [] + errors: list[str] = [] + for embed in embeds: + extractor = resolve_hoster(embed) + if extractor is None: + errors.append(f"Hébergeur non supporté : {embed}") + logger.warning("Aucun extracteur pour %s", embed) + continue + try: + link: VideoLink = await extractor.extract(embed) + data = dataclasses.asdict(link) + data["embed_url"] = embed + links.append(data) + except ScrapeError as exc: + errors.append(str(exc)) + logger.error("Extraction échouée pour %s : %s", embed, exc) + + if not links and not embeds: + raise HTTPException(502, detail="Aucun lecteur trouvé sur la page de l'épisode") + return {"episode_url": episode_url, "links": links, "errors": errors} + + +def _find_source_for_url(url: str) -> SourceScraper: + for source in all_sources(): + if source.base_url.split("//")[-1].split("/")[0] in url: + return source + raise ScrapeError(f"Aucune source ne correspond à l'URL : {url}") diff --git a/app/routers/torznab.py b/app/routers/torznab.py new file mode 100644 index 0000000..023cd06 --- /dev/null +++ b/app/routers/torznab.py @@ -0,0 +1,124 @@ +"""API Torznab/Newznab consommée par Sonarr, Prowlarr (et les *arr en général). + +Authentification par clé API (`?apikey=…` ou en-tête `X-Api-Key`), indépendante +des sessions utilisateurs — aucun cookie requis. +""" + +import logging +import secrets + +from fastapi import APIRouter, Request, Response + +from app.scrapers.base import ScrapeError +from app.services.settings import get_torznab_apikey +from app.services.torznab import torrent_stub, torznab + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/torznab", tags=["torznab"]) + + +def _base_url(request: Request) -> str: + return str(request.base_url).rstrip("/") + + +async def _auth_error(request: Request, apikey: str | None) -> Response | None: + """Réponse d'erreur XML si la clé est invalide (None = authentifié).""" + expected = await get_torznab_apikey() + provided = apikey or request.headers.get("X-Api-Key") or "" + if secrets.compare_digest(provided, expected): + return None + return Response( + torznab.error_xml(100, "Invalid API key"), + media_type="application/xml", + status_code=401, + ) + + +@router.get("/api") +@router.post("/api") +async def torznab_api( + request: Request, + t: str = "caps", + q: str | None = None, + season: int | None = None, + ep: int | None = None, + apikey: str | None = None, +) -> Response: + """Point d'entrée Torznab : caps / tvsearch / search.""" + error = await _auth_error(request, apikey) + if error is not None: + return error + key = await get_torznab_apikey() + base = _base_url(request) + + if t == "caps": + return Response(torznab.caps_xml(base), media_type="application/xml") + + if t in ("tvsearch", "search"): + if not q or len(q) < 2: + return Response( + torznab.error_xml(200, "Paramètre q requis"), + media_type="application/xml", + status_code=400, + ) + try: + releases = await torznab.tvsearch(q, season=season, ep=ep) + except Exception as exc: # noqa: BLE001 — Sonarr attend du XML, pas un traceback + logger.error("Torznab %s %r KO : %s", t, q, exc) + return Response( + torznab.error_xml(300, "Recherche indisponible"), + media_type="application/xml", + status_code=502, + ) + return Response( + torznab.results_xml(base, key, releases), media_type="application/rss+xml" + ) + + return Response( + torznab.error_xml(203, f"Fonction non supportée : {t}"), + media_type="application/xml", + status_code=400, + ) + + +@router.get("/download") +async def torznab_download( + request: Request, + source: str | None = None, + sid: str | None = None, + season: int | None = None, + ep: int | None = None, + series: str | None = None, + apikey: str | None = None, +) -> Response: + """Grab : Sonarr récupère le « .torrent » ; OhmStreaming télécharge l'épisode. + + Le flux retourne un .torrent de service (blackhole-friendly) pendant que + l'épisode réel entre dans la file de téléchargements interne. + """ + error = await _auth_error(request, apikey) + if error is not None: + return error + if None in (source, sid, season, ep, series): + return Response( + torznab.error_xml(201, "Paramètres manquants : source, sid, season, ep, series"), + media_type="application/xml", + status_code=400, + ) + try: + result = await torznab.grab(source, sid, season, ep, series) + logger.info("Torznab grab OK : %s → download %s", series, result.get("id")) + except ScrapeError as exc: + logger.error("Torznab grab KO : %s", exc) + return Response( + torznab.error_xml(300, str(exc)), + media_type="application/xml", + status_code=502, + ) + stub = torrent_stub(_base_url(request) + "/torznab/api", f"{series} S{season:02d}E{ep:02d}") + return Response( + stub, + media_type="application/x-bittorrent", + headers={"Content-Disposition": f'attachment; filename="ohm-{series.replace("/", "-")}-S{season:02d}E{ep:02d}.torrent"'}, + ) diff --git a/app/scrapers/__init__.py b/app/scrapers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/scrapers/base.py b/app/scrapers/base.py new file mode 100644 index 0000000..f2459b3 --- /dev/null +++ b/app/scrapers/base.py @@ -0,0 +1,192 @@ +"""Contrats et registres des scrapers. + +Architecture à 2 registres : +- sources (sites de catalogues animes/séries) : recherche, détails, épisodes, liens embed +- hébergeurs vidéo : résolution d'une URL embed → URL directe du fichier + +Ajouter une source = écrire une classe qui implémente le contrat et la décorer +@register_source / @register_hoster. +""" + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + + +class ScrapeError(Exception): + """Échec de scraping/extraction — jamais silencieux, toujours journalisé et remonté.""" + + +# ---------------------------------------------------------------- modèles + + +@dataclass +class SearchResult: + source: str + source_id: str # identifiant stable chez la source (slug, id…) + title: str + url: str + image_url: str | None = None + media_type: str = "anime" # anime | serie | film + + +@dataclass +class Episode: + number: float # 1, 2, 2.5 pour les OAV intermédiaires + title: str | None + url: str # page de l'épisode chez la source + season: int = 1 + + +@dataclass +class TitleDetails: + source: str + source_id: str + title: str + url: str + synopsis: str | None = None + image_url: str | None = None + banner_url: str | None = None + genres: list[str] = field(default_factory=list) + rating: float | None = None + year: int | None = None + episode_count: int | None = None + episodes: list[Episode] = field(default_factory=list) + media_type: str = "anime" + + +@dataclass +class VideoLink: + """Lien vidéo résolu (URL directe du fichier, lisible par un lecteur).""" + + url: str + hoster: str + quality: str | None = None + headers: dict[str, str] = field(default_factory=dict) # ex. Referer obligatoire + is_hls: bool = False + + +# ---------------------------------------------------------------- contrats + + +class SourceScraper(ABC): + """Contrat d'un site catalogue (animes, séries…).""" + + name: str # identifiant unique, ex. "vostfree" + label: str # nom affiché + base_url: str + media_types: tuple[str, ...] = ("anime",) + + @abstractmethod + async def search(self, query: str) -> list[SearchResult]: ... + + @abstractmethod + async def get_details(self, source_id: str) -> TitleDetails: ... + + @abstractmethod + async def list_episodes(self, source_id: str) -> list[Episode]: ... + + @abstractmethod + async def extract_embed_links(self, episode_url: str) -> list[str]: + """URLs des lecteurs embarqués trouvés sur la page d'un épisode.""" + ... + + async def latest(self) -> list[SearchResult]: + """Ajouts récents du catalogue (découverte). Vide si la source ne le supporte pas.""" + return [] + + +class HosterExtractor(ABC): + """Contrat d'un hébergeur vidéo : embed URL → URL directe.""" + + name: str + domains: tuple[str, ...] = () + + def can_handle(self, url: str) -> bool: + return any(d in url for d in self.domains) + + @abstractmethod + async def extract(self, embed_url: str) -> VideoLink: ... + + +# ---------------------------------------------------------------- registres + +_source_registry: dict[str, type[SourceScraper]] = {} +_hoster_registry: list[type[HosterExtractor]] = [] + + +def register_source(cls: type[SourceScraper]) -> type[SourceScraper]: + if cls.name in _source_registry: + raise ValueError(f"Source déjà enregistrée : {cls.name}") + _source_registry[cls.name] = cls + logger.debug("Source enregistrée : %s", cls.name) + return cls + + +def register_hoster(cls: type[HosterExtractor]) -> type[HosterExtractor]: + _hoster_registry.append(cls) + logger.debug("Hébergeur enregistré : %s", cls.name) + return cls + + +_source_instances: dict[str, SourceScraper] = {} +_hoster_instances: list[HosterExtractor] | None = None + + +def get_source(name: str) -> SourceScraper: + if name not in _source_instances: + cls = _source_registry.get(name) + if cls is None: + raise ScrapeError(f"Source inconnue : {name}") + _source_instances[name] = cls() + return _source_instances[name] + + +def all_sources() -> list[SourceScraper]: + return [get_source(name) for name in _source_registry] + + +def resolve_hoster(url: str) -> HosterExtractor | None: + """Trouve l'extracteur capable de traiter une URL embed (None → générique).""" + global _hoster_instances + if _hoster_instances is None: + _hoster_instances = [cls() for cls in _hoster_registry] + for extractor in _hoster_instances: + if extractor.can_handle(url): + return extractor + return None + + +def import_all_scrapers() -> None: + """Importe tous les modules pour déclencher les décorateurs d'enregistrement.""" + import importlib + import pkgutil + + import app.scrapers.hosters as hosters_pkg + import app.scrapers.sources as sources_pkg + + for pkg in (sources_pkg, hosters_pkg): + for mod in pkgutil.iter_modules(pkg.__path__): + importlib.import_module(f"{pkg.__name__}.{mod.name}") + + +# ---------------------------------------------------------------- format interne + +INTERNAL_SEP = "|" + + +def encode_internal_url(video_url: str, page_url: str, title: str) -> str: + """Format interne `video_url|page_url|titre` pour transporter le contexte.""" + for part in (video_url, page_url, title): + if INTERNAL_SEP in part: + raise ValueError(f"Caractère interdit '{INTERNAL_SEP}' dans : {part!r}") + return INTERNAL_SEP.join([video_url, page_url, title]) + + +def decode_internal_url(value: str) -> tuple[str, str, str]: + parts = value.split(INTERNAL_SEP) + if len(parts) != 3 or not parts[0]: + raise ValueError(f"URL interne invalide : {value!r}") + return parts[0], parts[1], parts[2] diff --git a/app/scrapers/config_loader.py b/app/scrapers/config_loader.py new file mode 100644 index 0000000..8c66aad --- /dev/null +++ b/app/scrapers/config_loader.py @@ -0,0 +1,31 @@ +"""Chargement des configurations de scraping externalisées (sélecteurs YAML). + +Permet de réparer un site cassé en modifiant un fichier de config sans toucher +au code. Chaque source peut avoir un fichier `configs/.yaml`. +""" + +import logging +from functools import lru_cache +from typing import Any + +import yaml + +from app.config import get_settings + +logger = logging.getLogger(__name__) + + +@lru_cache +def load_scraper_config(source_name: str) -> dict[str, Any]: + path = get_settings().scrapers_config_dir / f"{source_name}.yaml" + if not path.exists(): + logger.debug("Pas de config externe pour %s (%s)", source_name, path) + return {} + try: + with path.open(encoding="utf-8") as fh: + config = yaml.safe_load(fh) or {} + logger.info("Config de scraping chargée pour %s", source_name) + return config + except yaml.YAMLError as exc: + logger.error("Config %s invalide : %s", path, exc) + return {} diff --git a/app/scrapers/configs/french_manga.yaml b/app/scrapers/configs/french_manga.yaml new file mode 100644 index 0000000..cda1417 --- /dev/null +++ b/app/scrapers/configs/french_manga.yaml @@ -0,0 +1,29 @@ +# Sélecteurs de scraping French-Manga (DataLife Engine + API AJAX maison) +# Structure vérifiée en live sur https://w16.french-manga.net + +search: + endpoint: "/engine/ajax/search.php" # POST query=&page=1 (la recherche DLE native est cassée) + result: ".search-item" # lien dans l'attribut onclick + title: ".search-title" + image: ".search-poster img" + +details: + config_el: "#serie-config" # data-title / data-news-id + poster: ".fposter img" + synopsis: ".flist .fdesc p" + release: ".facts .release" # année + genres: ".facts .genres a" + episode_badge: ".short-meta.short-label" # "Ep 32 sur 32" + +episodes: + api: "/engine/ajax/manga_episodes_api.php" # ?id= → JSON {vf, vostfr, info} + versions: ["vostfr", "vf"] # priorité des versions + fragment_prefix: "ep" # URL épisode : #ep=- + +# Nouveautés — section « Récemment mises à jour » de la page d'accueil épinglée +latest: + path: "/manga-streaming-1/" + item: ".short" + link: "a.short-poster" # href = index.php?newsid=, alt = titre + title: ".short-title" + image: "a.short-poster img" diff --git a/app/scrapers/configs/vostfree.yaml b/app/scrapers/configs/vostfree.yaml new file mode 100644 index 0000000..b6e3143 --- /dev/null +++ b/app/scrapers/configs/vostfree.yaml @@ -0,0 +1,47 @@ +# Sélecteurs de scraping Vostfree (DataLife Engine) — surchargeables sans toucher au code. +# Structure vérifiée en live sur https://ipv4.vostfree.ws + +search: + result: "div.search-result" # bloc d'un résultat de recherche + link: "div.title a" # lien + titre + image: "span.image img" # poster + genres: "ul.additional li" # "Genre:...", "Anneé:..." (détection film) + +details: + title: "h1" + poster: ".slide-poster img" + synopsis: ".slide-desc" # les .cast internes sont retirés + genres: '.slide-top li.right a[href*="/genre/"]' + episode_badge: ".slide-poster .year" # ex. "Episode 293" + season_li: "ul.slide-top li" # ex. "Saison: 01" + +episodes: + option: "select.new_player_selector option" # value="buttons_N" → Episode NN + button: "div.button_box" # repli si pas de sélecteur + content_prefix: "content_" # #player_M → #content_player_M + +# Nouveautés — page « Animes VOSTFR récemment ajoutés » +latest: + path: "/animes-vostfr-recement-ajoutees.html" + item: "div.movie-poster" + link: ".play a" # href = fiche, alt = titre + image: "span.image img" +# class CSS du lecteur → template d'URL ({} = valeur de #content_player_M) +# chaîne vide = la valeur est déjà une URL complète +players: + new_player_vip: "" + new_player_moevideo: "" + new_player_sibnet: "https://video.sibnet.ru/shell.php?videoid={}" + new_player_netu: "https://video.sibnet.ru/shell.php?videoid={}" + new_player_uqload: "https://uqload.com/embed-{}.html" + new_player_mp4: "https://www.mp4upload.com/embed-{}.html" + new_player_fembed: "https://www.fembed.com/v/{}" + new_player_mytv: "https://www.myvi.top/embed/{}" + new_player_myvi: "https://myvi.ru/player/embed/html/{}" + new_player_rutube: "https://rutube.ru/play/embed/{}" + new_player_ok: "https://ok.ru/video/{}" + new_player_mail2: "https://my.mail.ru/video/embed/{}" + new_player_rapids: "https://rapidstream.co/embed-{}.html" + new_player_gtv: "https://iframedream.com/embed/{}.html" + new_player_cloudvideo: "https://cloudvideo.tv/embed-{}.html" + new_player_uptostream: "https://uptostream.com/iframe/{}" diff --git a/app/scrapers/hosters/__init__.py b/app/scrapers/hosters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/scrapers/hosters/_packer.py b/app/scrapers/hosters/_packer.py new file mode 100644 index 0000000..ffac2ed --- /dev/null +++ b/app/scrapers/hosters/_packer.py @@ -0,0 +1,40 @@ +"""Décompresseur pour le JavaScript packé « p,a,c,k,e,d » (Dean Edwards). + +Utilisé par plusieurs hébergeurs vidéo (Uqload, VidMoly…) pour masquer +l'URL du lecteur : le HTML contient `eval(function(p,a,c,k,e,d){...}(...))`. +""" + +import re + +_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +_PACKED_RE = re.compile( + r"\}\('(?P.*?)',(?P\d+),(?P\d+),'(?P.*?)'\.split\('\|'\)", + re.DOTALL, +) + + +def _base_n(number: int, base: int) -> str: + if number == 0: + return "0" + chars = [] + while number: + number, rest = divmod(number, base) + chars.append(_DIGITS[rest]) + return "".join(reversed(chars)) + + +def unpack_packed_js(text: str) -> str | None: + """Retourne le JS dépacké si `text` contient un bloc packé, sinon None.""" + match = _PACKED_RE.search(text) + if not match: + return None + payload = match.group("payload").replace("\\\\", "\\").replace("\\'", "'") + base, count = int(match.group("a")), int(match.group("c")) + keys = match.group("keys").split("|") + for index in range(count - 1, -1, -1): + token = _base_n(index, base) + if keys[index]: + replacement = keys[index].replace("\\", "\\\\") + payload = re.sub(rf"\b{re.escape(token)}\b", replacement, payload) + return payload diff --git a/app/scrapers/hosters/luluvdo.py b/app/scrapers/hosters/luluvdo.py new file mode 100644 index 0000000..f58de7c --- /dev/null +++ b/app/scrapers/hosters/luluvdo.py @@ -0,0 +1,50 @@ +"""Extracteur Luluvid/Luluvdo (luluvdo.com, luluvid.com, lulustream.com) — embed → m3u8. + +Famille StreamSB : la page embed configure jwplayer dans du JS packé +(p,a,c,k,e,d) avec `sources:[{file:"https://.../master.m3u8?t="}]`. +Le token CDN (tnmr.org…) est lié à l'IP **et à l'User-Agent** de la requête +d'embed : la VideoLink doit donc renvoyer le User-Agent du socle pour que le +téléchargement passe — sans lui, le CDN répond 403. +""" + +import logging +import re +from urllib.parse import urlparse + +from app.config import get_settings +from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster +from app.scrapers.hosters._packer import unpack_packed_js +from app.scrapers.http import fetch + +logger = logging.getLogger(__name__) + +_PATTERNS = ( + re.compile(r'sources\s*:\s*\[\s*\{[^}]*?file\s*:\s*["\'](?P[^"\']+\.m3u8[^"\']*)["\']'), + re.compile(r'file\s*:\s*["\'](?Phttps?://[^"\']+?\.m3u8[^"\']*)["\']'), + re.compile(r'["\'](?Phttps?://[^"\']*?\.m3u8(?:\?[^"\']*)?)["\']'), +) + + +@register_hoster +class LuluvdoExtractor(HosterExtractor): + name = "luluvdo" + domains = ("luluvdo.com", "luluvid.com", "lulustream.com") + + async def extract(self, embed_url: str) -> VideoLink: + html = await fetch(embed_url) + for content in (html, unpack_packed_js(html) or ""): + for pattern in _PATTERNS: + for match in pattern.finditer(content): + url = match.group("url") + if url.startswith("http"): + return VideoLink( + url=url, + hoster=self.name, + headers={ + "Referer": f"https://{urlparse(embed_url).hostname}/", + "User-Agent": get_settings().user_agent, + "Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8", + }, + is_hls=".m3u8" in url, + ) + raise ScrapeError(f"luluvdo : URL vidéo introuvable dans {embed_url}") diff --git a/app/scrapers/hosters/sendvid.py b/app/scrapers/hosters/sendvid.py new file mode 100644 index 0000000..bdf1772 --- /dev/null +++ b/app/scrapers/hosters/sendvid.py @@ -0,0 +1,51 @@ +"""Extracteur SendVid (sendvid.com) — page embed → mp4 direct. + +La page embed expose soit une balise `` ( lecteur HTML5), +soit une variable JS `video_source`. Extraction par regex, sans dépendance JS. +""" + +import logging +import re + +from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster +from app.scrapers.hosters._packer import unpack_packed_js +from app.scrapers.http import fetch + +logger = logging.getLogger(__name__) + +_PATTERNS = ( + re.compile(r']+src=["\'](?P[^"\']+)["\']'), + re.compile(r'video_source\s*[:=]\s*["\'](?P[^"\']+)["\']'), + re.compile(r'["\'](?Phttps?://[^"\']*?\.(?:mp4|m3u8)(?:\?[^"\']*)?)["\']'), +) + + +@register_hoster +class SendvidExtractor(HosterExtractor): + name = "sendvid" + domains = ("sendvid.com",) + + async def extract(self, embed_url: str) -> VideoLink: + html = await fetch(embed_url) + candidates: list[str] = [] + for content in (html, unpack_packed_js(html) or ""): + for pattern in _PATTERNS: + for match in pattern.finditer(content): + candidates.append(match.group("url")) + url = self._pick(candidates) + if not url: + raise ScrapeError(f"sendvid : URL vidéo introuvable dans {embed_url}") + return VideoLink( + url=url, + hoster=self.name, + headers={"Referer": embed_url}, + is_hls=".m3u8" in url, + ) + + @staticmethod + def _pick(candidates: list[str]) -> str | None: + video = [u for u in candidates if re.search(r"\.(mp4|m3u8)(\?|$)", u)] + for url in video: + if ".mp4" in url: + return url + return video[0] if video else None diff --git a/app/scrapers/hosters/sibnet.py b/app/scrapers/hosters/sibnet.py new file mode 100644 index 0000000..e9c50fa --- /dev/null +++ b/app/scrapers/hosters/sibnet.py @@ -0,0 +1,69 @@ +"""Extracteur Sibnet (video.sibnet.ru) — page shell/watch → mp4 direct. + +La page embed `shell.php?videoid=N` (ou la page publique `/videoN`) contient +une config jwplayer du type `player.src([{src: "/v//.mp4"}])`. +`shell.php` répond 403 depuis certains réseaux : on retombe alors sur la page +publique de la vidéo, qui expose la même config. +""" + +import logging +import re +from urllib.parse import urljoin + +from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster +from app.scrapers.http import fetch + +logger = logging.getLogger(__name__) + +_BASE = "https://video.sibnet.ru" +_VIDEO_ID_RE = re.compile(r"(?:videoid=|/video)(\d+)") +_MP4_PATTERNS = ( + re.compile(r'player\.src\(\[\{src:\s*"(?P/v/[^"]+?\.mp4)"'), + re.compile(r'["\'](?P/v/[0-9a-f]{32}/\d+\.mp4)["\']'), + re.compile(r'["\'](?Phttps?://[^"\']*?/v/[^"\']+?\.mp4)["\']'), +) + + +@register_hoster +class SibnetExtractor(HosterExtractor): + name = "sibnet" + domains = ("video.sibnet.ru",) + + async def extract(self, embed_url: str) -> VideoLink: + match = _VIDEO_ID_RE.search(embed_url) + if not match: + raise ScrapeError(f"sibnet : identifiant vidéo introuvable dans {embed_url}") + video_id = match.group(1) + watch_url = f"{_BASE}/video{video_id}" + + pages: list[tuple[str, str]] = [] + try: + pages.append(("embed", await fetch(embed_url, retries=0))) + except ScrapeError as exc: + logger.info( + "sibnet : page embed %s inaccessible (%s), essai page publique", embed_url, exc + ) + try: + pages.append(("watch", await fetch(watch_url, retries=1))) + except ScrapeError as exc: + logger.error("sibnet : page publique %s inaccessible : %s", watch_url, exc) + + for source, html in pages: + url = self._find_mp4(html, source) + if url: + return VideoLink( + url=url, + hoster=self.name, + headers={"Referer": watch_url}, + is_hls=False, + ) + raise ScrapeError(f"sibnet : URL mp4 introuvable pour la vidéo {video_id}") + + def _find_mp4(self, html: str, source: str) -> str | None: + for pattern in _MP4_PATTERNS: + match = pattern.search(html) + if match: + url = urljoin(_BASE + "/", match.group("url")) + logger.debug("sibnet : mp4 trouvé via %s → %s", source, url) + return url + return None diff --git a/app/scrapers/hosters/uqload.py b/app/scrapers/hosters/uqload.py new file mode 100644 index 0000000..721fa0f --- /dev/null +++ b/app/scrapers/hosters/uqload.py @@ -0,0 +1,45 @@ +"""Extracteur Uqload (uqload.to/.co/.com/.io) — page embed → mp4/m3u8. + +La page embed contient un jwplayer configuré dans du JS packé +(p,a,c,k,e,d) : `sources:[{file:"https://.../master.m3u8?..."}]`. +On dépacke puis on extrait par regex. +""" + +import logging +import re + +from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster +from app.scrapers.hosters._packer import unpack_packed_js +from app.scrapers.http import fetch + +logger = logging.getLogger(__name__) + +_PATTERNS = ( + re.compile(r'sources\s*:\s*\[\s*\{[^}]*?(?:file|src)\s*:\s*["\'](?P[^"\']+)["\']'), + re.compile(r'(?:file|src)\s*:\s*["\'](?Phttps?://[^"\']+?\.(?:m3u8|mp4)[^"\']*)["\']'), + re.compile(r'["\'](?Phttps?://[^"\']*?\.(?:m3u8|mp4)(?:\?[^"\']*)?)["\']'), +) + + +@register_hoster +class UqloadExtractor(HosterExtractor): + name = "uqload" + domains = ("uqload.to", "uqload.co", "uqload.com", "uqload.io") + + async def extract(self, embed_url: str) -> VideoLink: + html = await fetch(embed_url) + candidates: list[str] = [] + for content in (html, unpack_packed_js(html) or ""): + for pattern in _PATTERNS: + for match in pattern.finditer(content): + candidates.append(match.group("url")) + video = [u for u in candidates if re.search(r"\.(mp4|m3u8)(\?|$)", u)] + url = next((u for u in video if ".mp4" in u), None) or (video[0] if video else None) + if not url: + raise ScrapeError(f"uqload : URL vidéo introuvable dans {embed_url}") + return VideoLink( + url=url, + hoster=self.name, + headers={"Referer": embed_url}, + is_hls=".m3u8" in url, + ) diff --git a/app/scrapers/hosters/vidmoly.py b/app/scrapers/hosters/vidmoly.py new file mode 100644 index 0000000..22664ec --- /dev/null +++ b/app/scrapers/hosters/vidmoly.py @@ -0,0 +1,44 @@ +"""Extracteur VidMoly (vidmoly.to / vidmoly.me) — page embed → mp4/m3u8. + +Le lecteur jwplayer est configuré dans du JS parfois packé (p,a,c,k,e,d) : +`sources: [{file: "...m3u8"}]`. On dépacke si besoin puis on extrait par regex. +""" + +import logging +import re + +from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster +from app.scrapers.hosters._packer import unpack_packed_js +from app.scrapers.http import fetch + +logger = logging.getLogger(__name__) + +_PATTERNS = ( + re.compile(r'sources\s*:\s*\[\s*\{[^}]*?file\s*:\s*["\'](?P[^"\']+)["\']'), + re.compile(r'file\s*:\s*["\'](?Phttps?://[^"\']+?\.(?:m3u8|mp4)[^"\']*)["\']'), + re.compile(r'["\'](?Phttps?://[^"\']*?\.(?:m3u8|mp4)(?:\?[^"\']*)?)["\']'), +) + + +@register_hoster +class VidmolyExtractor(HosterExtractor): + name = "vidmoly" + domains = ("vidmoly.to", "vidmoly.me", "vidmoly.biz", "vidmoly.com") + + async def extract(self, embed_url: str) -> VideoLink: + html = await fetch(embed_url) + candidates: list[str] = [] + for content in (html, unpack_packed_js(html) or ""): + for pattern in _PATTERNS: + for match in pattern.finditer(content): + candidates.append(match.group("url")) + video = [u for u in candidates if re.search(r"\.(mp4|m3u8)(\?|$)", u)] + url = next((u for u in video if ".mp4" in u), None) or (video[0] if video else None) + if not url: + raise ScrapeError(f"vidmoly : URL vidéo introuvable dans {embed_url}") + return VideoLink( + url=url, + hoster=self.name, + headers={"Referer": embed_url}, + is_hls=".m3u8" in url, + ) diff --git a/app/scrapers/hosters/vidzy.py b/app/scrapers/hosters/vidzy.py new file mode 100644 index 0000000..1adc38a --- /dev/null +++ b/app/scrapers/hosters/vidzy.py @@ -0,0 +1,78 @@ +"""Extracteur Vidzy (vidzy.org) — page embed videojs → m3u8. + +La page embed ne contient pas l'URL vidéo en clair : le script videojs appelle +une fonction de décodage inline `atob(s)` + reverse + XOR, avec une graine +dérivée du hostname (`somme des codes des caractères & 0xFF`). Si le décodage +échoue, la page sert un leurre `https://s1.fsvid.lol/troll/master.m3u8` +(même valeur pour tous les épisodes) — on le rejette explicitement. + +Algorithme (reproduit fidèlement depuis le JS de la page) : + b = base64decode(s) ; a = b[::-1] + r[i] = chr(a[i] ^ ((0x3D + i*89 + H) & 0xFF)) avec H = sum(ord(hostname)) & 0xFF +""" + +import base64 +import logging +import re +from urllib.parse import urlparse + +from app.config import get_settings +from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster +from app.scrapers.http import fetch + +logger = logging.getLogger(__name__) + +_B64_RE = re.compile( + r"atob\(s\)(?s:.){0,800}?\}\)\(\"(?P[A-Za-z0-9+/=]{50,})\"", +) +_CLEAR_PATTERNS = ( + re.compile(r'sources\s*:\s*\[\s*\{\s*src\s*:\s*["\'](?Phttps?://[^"\']+?\.m3u8[^"\']*)["\']'), + re.compile(r'_fsvHls\s*=\s*"(?Phttps?://[^"]+?\.m3u8[^"]*)"'), +) +_TROLL = "/troll/" + + +def _decode(b64: str, hostname: str) -> str | None: + try: + data = base64.b64decode(b64) + except (ValueError, TypeError): + return None + seed = sum(ord(c) for c in hostname) & 0xFF + decoded = "".join(chr(b ^ ((0x3D + i * 89 + seed) & 0xFF)) for i, b in enumerate(data[::-1])) + return decoded if decoded.startswith(("http://", "https://")) else None + + +@register_hoster +class VidzyExtractor(HosterExtractor): + name = "vidzy" + domains = ("vidzy.org",) + + async def extract(self, embed_url: str) -> VideoLink: + html = await fetch(embed_url) + hostname = urlparse(embed_url).hostname or "" + + for match in _B64_RE.finditer(html): + url = _decode(match.group("b64"), hostname) + if url and _TROLL not in url: + return self._video_link(url, embed_url) + logger.warning("vidzy : décodage atob+XOR sans résultat pour %s", embed_url) + + for pattern in _CLEAR_PATTERNS: + for match in pattern.finditer(html): + url = match.group("url") + if _TROLL not in url: + return self._video_link(url, embed_url) + raise ScrapeError( + f"vidzy : URL vidéo introuvable dans {embed_url} (leurre anti-bot ou page modifiée)" + ) + + def _video_link(self, url: str, embed_url: str) -> VideoLink: + return VideoLink( + url=url, + hoster=self.name, + headers={ + "Referer": f"https://{urlparse(embed_url).hostname}/", + "User-Agent": get_settings().user_agent, + }, + is_hls=".m3u8" in url, + ) diff --git a/app/scrapers/http.py b/app/scrapers/http.py new file mode 100644 index 0000000..203ea4c --- /dev/null +++ b/app/scrapers/http.py @@ -0,0 +1,63 @@ +"""Client HTTP partagé pour le scraping (httpx async, headers navigateur, retries).""" + +import asyncio +import logging + +import httpx +from bs4 import BeautifulSoup + +from app.config import get_settings +from app.scrapers.base import ScrapeError + +logger = logging.getLogger(__name__) + +_client: httpx.AsyncClient | None = None + + +def get_client() -> httpx.AsyncClient: + global _client + if _client is None: + settings = get_settings() + _client = httpx.AsyncClient( + timeout=settings.http_timeout, + follow_redirects=True, + headers={ + "User-Agent": settings.user_agent, + "Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8", + }, + ) + return _client + + +async def close_client() -> None: + global _client + if _client is not None: + await _client.aclose() + _client = None + + +async def fetch( + url: str, + *, + referer: str | None = None, + retries: int = 2, +) -> str: + """GET d'une page avec retries ; lève ScrapeError en cas d'échec définitif.""" + headers = {"Referer": referer} if referer else {} + last_error: Exception | None = None + for attempt in range(retries + 1): + try: + response = await get_client().get(url, headers=headers) + response.raise_for_status() + return response.text + except (httpx.HTTPError, httpx.InvalidURL) as exc: + last_error = exc + logger.warning("fetch %s — tentative %d/%d : %s", url, attempt + 1, retries + 1, exc) + if attempt < retries: + await asyncio.sleep(1.0 * (attempt + 1)) + raise ScrapeError(f"Échec de récupération de {url} : {last_error}") + + +async def fetch_soup(url: str, *, referer: str | None = None) -> BeautifulSoup: + html = await fetch(url, referer=referer) + return BeautifulSoup(html, "lxml") diff --git a/app/scrapers/sources/__init__.py b/app/scrapers/sources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/scrapers/sources/french_manga.py b/app/scrapers/sources/french_manga.py new file mode 100644 index 0000000..096327f --- /dev/null +++ b/app/scrapers/sources/french_manga.py @@ -0,0 +1,313 @@ +"""Source French-Manga (w16.french-manga.net) — animes VF/VOSTFR, moteur DataLife Engine. + +Faits structurels (vérifiés en live) : +- La recherche native DLE est cassée (erreur MySQL en GET, « moins de 4 caractères » en POST) ; + le site utilise un endpoint AJAX : POST /engine/ajax/search.php avec `query=&page=1` + → blocs `.search-item` (lien dans l'attribut onclick). +- Nouveautés : /manga-streaming-1/ (« Récemment mises à jour ») → blocs `.short` + (lien `a.short-poster` href = `index.php?newsid=`, titre `.short-title`, poster img). +- Fiche : `/-.html` (alias canonique `index.php?newsid=`), métadonnées dans + `.facts`, `.fdesc`, `.fposter` et `#serie-config` (data-title, data-news-id). +- Épisodes et lecteurs : GET /engine/ajax/manga_episodes_api.php?id= → JSON + {"vf": {ep: {hoster: url}}, "vostfr": {...}, "info": {ep: {title, poster}}}. +- Pas de page par épisode : l'URL d'épisode est `#ep=-`. +""" + +import asyncio +import json +import logging +import re +from copy import deepcopy +from urllib.parse import urljoin + +import httpx +from bs4 import BeautifulSoup, Tag + +from app.scrapers.base import ( + Episode, + ScrapeError, + SearchResult, + SourceScraper, + TitleDetails, + register_source, +) +from app.scrapers.config_loader import load_scraper_config +from app.scrapers.http import fetch, fetch_soup, get_client + +logger = logging.getLogger(__name__) + +DEFAULT_CONFIG: dict = { + "search": { + "endpoint": "/engine/ajax/search.php", + "result": ".search-item", + "title": ".search-title", + "image": ".search-poster img", + }, + "details": { + "config_el": "#serie-config", + "poster": ".fposter img", + "synopsis": ".flist .fdesc p", + "release": ".facts .release", + "genres": ".facts .genres a", + "episode_badge": ".short-meta.short-label", + "episodes": { + "api": "/engine/ajax/manga_episodes_api.php", + "versions": ["vostfr", "vf"], + "fragment_prefix": "ep", + }, + "latest": { + "path": "/manga-streaming-1/", # « Récemment mises à jour » + "item": ".short", + "link": "a.short-poster", # href = index.php?newsid=, alt = titre + "title": ".short-title", + "image": "a.short-poster img", + }, + }, +} + +_ID_RE = re.compile(r"newsid=(\d+)|/(\d+)-[^/]+\.html?") +_NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)") +_SEASON_RE = re.compile(r"Saison\s+(\d+)", re.IGNORECASE) +_YEAR_RE = re.compile(r"\((\d{4})\)\s*$") + + +def _merged_config() -> dict: + merged = deepcopy(DEFAULT_CONFIG) + for key, values in load_scraper_config("french_manga").items(): + if isinstance(values, dict) and isinstance(merged.get(key), dict): + merged[key].update(values) + else: + merged[key] = values + return merged + + +@register_source +class FrenchMangaScraper(SourceScraper): + name = "french_manga" + label = "French-Manga" + base_url = "https://w16.french-manga.net" + media_types = ("anime",) + + # ------------------------------------------------------------- helpers + + @staticmethod + def _id_from_url(url: str) -> str | None: + match = _ID_RE.search(url) + if not match: + return None + return match.group(1) or match.group(2) + + def _title_url(self, source_id: str) -> str: + return f"{self.base_url}/index.php?newsid={source_id}" + + async def _post(self, url: str, data: dict[str, str]) -> str: + """POST avec retries (fetch du socle est GET uniquement).""" + last_error: Exception | None = None + for attempt in range(3): + try: + response = await get_client().post(url, data=data) + response.raise_for_status() + return response.text + except (httpx.HTTPError, httpx.InvalidURL) as exc: + last_error = exc + logger.warning("french_manga POST %s — tentative %d/3 : %s", url, attempt + 1, exc) + await asyncio.sleep(1.0 * (attempt + 1)) + raise ScrapeError(f"Échec de récupération de {url} : {last_error}") + + async def _fetch_episodes_api(self, source_id: str) -> dict: + config = _merged_config() + api_url = urljoin(self.base_url + "/", config["episodes"]["api"]) + html = await fetch(f"{api_url}?id={source_id}", referer=self._title_url(source_id)) + try: + data = json.loads(html) + except json.JSONDecodeError as exc: + raise ScrapeError( + f"french_manga : réponse JSON invalide de l'API épisodes ({source_id}) : {exc}" + ) from exc + if not isinstance(data, dict): + raise ScrapeError(f"french_manga : réponse inattendue de l'API épisodes ({source_id})") + return data + + # ------------------------------------------------------------- search + + async def search(self, query: str) -> list[SearchResult]: + config = _merged_config() + search_cfg = config["search"] + endpoint = urljoin(self.base_url + "/", search_cfg["endpoint"]) + html = await self._post(endpoint, {"query": query, "page": "1"}) + soup = BeautifulSoup(html, "lxml") + results: list[SearchResult] = [] + for item in soup.select(search_cfg["result"]): + onclick = item.get("onclick", "") + match = re.search(r"location\.href='([^']+)'", onclick) + if not match: + logger.warning("french_manga : search-item sans lien (onclick=%r)", onclick[:80]) + continue + url = urljoin(self.base_url + "/", match.group(1)) + source_id = self._id_from_url(url) + if not source_id: + logger.warning("french_manga : identifiant introuvable dans %s", url) + continue + title_el = item.select_one(search_cfg["title"]) + title = title_el.get_text(strip=True) if title_el else url + image = item.select_one(search_cfg["image"]) + results.append( + SearchResult( + source=self.name, + source_id=source_id, + title=_YEAR_RE.sub("", title).strip() or title, + url=url, + image_url=image.get("src") if image else None, + media_type="film" if re.search(r"\bfilm\b", title, re.IGNORECASE) else "anime", + ) + ) + logger.info("french_manga : %d résultats pour %r", len(results), query) + return results + + # ------------------------------------------------------------- latest + + async def latest(self) -> list[SearchResult]: + """Ajouts récents — section « Récemment mises à jour ».""" + config = _merged_config() + latest_cfg = config["latest"] + url = urljoin(self.base_url + "/", latest_cfg["path"]) + soup = await fetch_soup(url) + results: list[SearchResult] = [] + for item in soup.select(latest_cfg["item"]): + link = item.select_one(latest_cfg["link"]) + href = link.get("href") if link else None + if not href: + logger.warning("french_manga : bloc nouveauté sans lien, ignoré") + continue + absolute = urljoin(self.base_url + "/", href) + source_id = self._id_from_url(absolute) + if not source_id: + logger.warning("french_manga : identifiant introuvable dans %s", absolute) + continue + image = item.select_one(latest_cfg["image"]) + title_el = item.select_one(latest_cfg["title"]) + title = title_el.get_text(strip=True) if title_el else (link.get("alt") or absolute) + title = _YEAR_RE.sub("", title).strip() or title + results.append( + SearchResult( + source=self.name, + source_id=source_id, + title=title, + url=self._title_url(source_id), + image_url=image.get("src") if image else None, + ) + ) + logger.info("french_manga : %d nouveautés récupérées", len(results)) + return results + + # ------------------------------------------------------------- details + + async def get_details(self, source_id: str) -> TitleDetails: + config = _merged_config() + url = self._title_url(source_id) + soup = await fetch_soup(url) + details_cfg = config["details"] + + config_el = soup.select_one(details_cfg["config_el"]) + news_id = config_el.get("data-news-id") if config_el else None + if news_id != source_id: + raise ScrapeError(f"french_manga : fiche introuvable pour {source_id} ({url})") + title = config_el.get("data-title") or ( + soup.title.get_text(strip=True) if soup.title else source_id + ) + + release = self._text(soup.select_one(details_cfg["release"])) + release_match = re.search(r"(\d{4})", release) + badge = self._text(soup.select_one(details_cfg["episode_badge"])) + badge_match = _NUMBER_RE.search(badge) + + episodes = await self._episodes_from_api(source_id, title, url) + + return TitleDetails( + source=self.name, + source_id=source_id, + title=title, + url=url, + synopsis=self._text(soup.select_one(details_cfg["synopsis"])) or None, + image_url=(soup.select_one(details_cfg["poster"]) or Tag(name="img")).get("src"), + genres=[a.get_text(strip=True) for a in soup.select(details_cfg["genres"])], + year=int(release_match.group(1)) if release_match else None, + episode_count=int(float(badge_match.group(1).replace(",", "."))) + if badge_match + else None, + episodes=episodes, + ) + + @staticmethod + def _text(element: Tag | None) -> str: + return element.get_text(" ", strip=True) if element else "" + + # ------------------------------------------------------------ episodes + + async def list_episodes(self, source_id: str) -> list[Episode]: + return await self._episodes_from_api(source_id, None, self._title_url(source_id)) + + async def _episodes_from_api( + self, source_id: str, title: str | None, page_url: str + ) -> list[Episode]: + config = _merged_config() + data = await self._fetch_episodes_api(source_id) + versions: list[str] = config["episodes"]["versions"] + season = 1 + if title: + season_match = _SEASON_RE.search(title) + if season_match: + season = int(season_match.group(1)) + + numbers: set[float] = set() + for version in versions: + for number_text in data.get(version) or {}: + try: + numbers.add(float(number_text)) + except ValueError: + logger.warning("french_manga : numéro d'épisode invalide %r", number_text) + + info = data.get("info") or {} + episodes: list[Episode] = [] + for number in sorted(numbers): + number_text = str(int(number)) if number.is_integer() else str(number) + episode_info = info.get(number_text) or info.get(str(int(number))) + label = episode_info.get("title") if isinstance(episode_info, dict) else None + version = next((v for v in versions if number_text in (data.get(v) or {})), versions[0]) + episodes.append( + Episode( + number=number, + title=label, + url=f"{page_url}#{config['episodes']['fragment_prefix']}={version}-{number_text}", + season=season, + ) + ) + if not episodes: + raise ScrapeError(f"french_manga : aucun épisode trouvé pour {source_id}") + return episodes + + # -------------------------------------------------------------- embeds + + async def extract_embed_links(self, episode_url: str) -> list[str]: + config = _merged_config() + page_url, _, fragment = episode_url.partition("#") + prefix = config["episodes"]["fragment_prefix"] + "=" + match = re.match(rf"{re.escape(prefix)}([A-Za-z0-9]+)-([\d.]+)$", fragment) + if not match: + raise ScrapeError(f"french_manga : fragment d'épisode invalide dans {episode_url}") + version, number_text = match.group(1), match.group(2) + if number_text.isdigit(): + number_text = str(int(number_text)) + + source_id = self._id_from_url(page_url) + if not source_id: + raise ScrapeError(f"french_manga : identifiant introuvable dans {episode_url}") + data = await self._fetch_episodes_api(source_id) + hosters = (data.get(version) or {}).get(number_text) + if not hosters: + raise ScrapeError( + f"french_manga : aucun lecteur pour {version} épisode {number_text} ({episode_url})" + ) + links = list(hosters.values()) + logger.info("french_manga : %d liens embed pour %s", len(links), episode_url) + return links diff --git a/app/scrapers/sources/vostfree.py b/app/scrapers/sources/vostfree.py new file mode 100644 index 0000000..b874f9c --- /dev/null +++ b/app/scrapers/sources/vostfree.py @@ -0,0 +1,327 @@ +"""Source Vostfree (ipv4.vostfree.ws) — moteur DataLife Engine, animes/films VF & VOSTFR. + +Faits structurels (vérifiés en live) : +- Recherche : GET /index.php?do=search&subaction=search&story= → blocs `div.search-result`. +- Nouveautés : /animes-vostfr-recement-ajoutees.html → blocs `div.movie-poster` + (lien `.play a`, alt = titre, poster `span.image img`). +- Fiche : `/444-telecharger-....html` — métadonnées dans `.slide-*`, épisodes dans un + `select.new_player_selector` (une `option value="buttons_N"` par épisode). +- Chaque `div#buttons_N` contient un ou plusieurs `div.new_player_#player_M` ; + l'URL embed est le texte de `div#content_player_M` (URL complète ou simple ID selon + l'hébergeur — les templates de reconstruction viennent du JS `anime.js` du site). +- Pas de page par épisode : l'URL d'épisode est `#buttons_N`. +""" + +import logging +import re +from copy import deepcopy +from urllib.parse import quote + +from bs4 import BeautifulSoup, Tag + +from app.scrapers.base import ( + Episode, + ScrapeError, + SearchResult, + SourceScraper, + TitleDetails, + register_source, +) +from app.scrapers.config_loader import load_scraper_config +from app.scrapers.http import fetch_soup + +logger = logging.getLogger(__name__) + +DEFAULT_CONFIG: dict = { + "search": { + "result": "div.search-result", + "link": "div.title a", + "image": "span.image img", + "genres": "ul.additional li", + }, + "details": { + "title": "h1", + "poster": ".slide-poster img", + "synopsis": ".slide-desc", + "genres": '.slide-top li.right a[href*="/genre/"]', + "episode_badge": ".slide-poster .year", + "season_li": "ul.slide-top li", + }, + "episodes": { + "option": "select.new_player_selector option", + "button": "div.button_box", + "content_prefix": "content_", + }, + "latest": { + "path": "/animes-vostfr-recement-ajoutees.html", + "item": "div.movie-poster", + "link": ".play a", # href = fiche, alt = titre + "image": "span.image img", + }, + # class CSS du lecteur → template d'URL ({} = valeur de #content_player_M ; + # chaîne vide = la valeur est déjà une URL complète) + "players": { + "new_player_vip": "", + "new_player_moevideo": "", + "new_player_sibnet": "https://video.sibnet.ru/shell.php?videoid={}", + "new_player_netu": "https://video.sibnet.ru/shell.php?videoid={}", + "new_player_uqload": "https://uqload.com/embed-{}.html", + "new_player_mp4": "https://www.mp4upload.com/embed-{}.html", + "new_player_fembed": "https://www.fembed.com/v/{}", + "new_player_mytv": "https://www.myvi.top/embed/{}", + "new_player_myvi": "https://myvi.ru/player/embed/html/{}", + "new_player_moevideo_mail": "", + "new_player_rutube": "https://rutube.ru/play/embed/{}", + "new_player_ok": "https://ok.ru/video/{}", + "new_player_mail2": "https://my.mail.ru/video/embed/{}", + "new_player_rapids": "https://rapidstream.co/embed-{}.html", + "new_player_gtv": "https://iframedream.com/embed/{}.html", + "new_player_cloudvideo": "https://cloudvideo.tv/embed-{}.html", + "new_player_uptostream": "https://uptostream.com/iframe/{}", + }, +} + +_SLUG_RE = re.compile(r"([^/]+)\.html?$") +_NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)") +_SEASON_RE = re.compile(r"Saison\s*:?\s*(\d+)", re.IGNORECASE) +_SAFE_FRAGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$") + + +def _merged_config() -> dict: + merged = deepcopy(DEFAULT_CONFIG) + for key, values in load_scraper_config("vostfree").items(): + if isinstance(values, dict) and isinstance(merged.get(key), dict): + merged[key].update(values) + else: + merged[key] = values + return merged + + +@register_source +class VostfreeScraper(SourceScraper): + name = "vostfree" + label = "Vostfree" + base_url = "https://ipv4.vostfree.ws" + media_types = ("anime",) + + # ------------------------------------------------------------- helpers + + def _title_url(self, source_id: str) -> str: + return f"{self.base_url}/{source_id}.html" + + @staticmethod + def _slug_from_url(url: str) -> str | None: + match = _SLUG_RE.search(url) + return match.group(1) if match else None + + @staticmethod + def _text(element: Tag | None) -> str: + return element.get_text(" ", strip=True) if element else "" + + # ------------------------------------------------------------- search + + async def search(self, query: str) -> list[SearchResult]: + config = _merged_config() + url = f"{self.base_url}/index.php?do=search&subaction=search&story={quote(query)}" + soup = await fetch_soup(url) + results: list[SearchResult] = [] + for block in soup.select(config["search"]["result"]): + link = block.select_one(config["search"]["link"]) + href = link.get("href") if link else None + if not href: + logger.warning("vostfree : bloc de résultat sans lien, ignoré") + continue + source_id = self._slug_from_url(href) + if not source_id: + logger.warning("vostfree : slug introuvable dans %s", href) + continue + image = block.select_one(config["search"]["image"]) + genres_text = " ".join( + li.get_text(" ", strip=True) for li in block.select(config["search"]["genres"]) + ) + results.append( + SearchResult( + source=self.name, + source_id=source_id, + title=self._text(link), + url=href, + image_url=image.get("src") if image else None, + media_type="film" + if re.search(r"\bfilm", genres_text, re.IGNORECASE) + else "anime", + ) + ) + logger.info("vostfree : %d résultats pour %r", len(results), query) + return results + + # ------------------------------------------------------------- latest + + async def latest(self) -> list[SearchResult]: + """Ajouts récents — page « Animes VOSTFR récemment ajoutés ».""" + config = _merged_config() + latest_cfg = config["latest"] + soup = await fetch_soup(f"{self.base_url}{latest_cfg['path']}") + results: list[SearchResult] = [] + for block in soup.select(latest_cfg["item"]): + link = block.select_one(latest_cfg["link"]) + href = link.get("href") if link else None + if not href: + logger.warning("vostfree : bloc nouveauté sans lien, ignoré") + continue + source_id = self._slug_from_url(href) + if not source_id: + logger.warning("vostfree : slug introuvable dans %s", href) + continue + image = block.select_one(latest_cfg["image"]) + title = link.get("alt") or self._text(link) + results.append( + SearchResult( + source=self.name, + source_id=source_id, + title=title, + url=href, + image_url=image.get("src") if image else None, + ) + ) + logger.info("vostfree : %d nouveautés récupérées", len(results)) + return results + # ------------------------------------------------------------- details + + async def get_details(self, source_id: str) -> TitleDetails: + config = _merged_config() + url = self._title_url(source_id) + soup = await fetch_soup(url) + details_cfg = config["details"] + + title_el = soup.select_one(details_cfg["title"]) + if title_el is None: + raise ScrapeError(f"vostfree : fiche introuvable pour {source_id} ({url})") + + synopsis_el = soup.select_one(details_cfg["synopsis"]) + if synopsis_el is not None: + for cast in synopsis_el.select(".cast"): + cast.decompose() + synopsis = self._text(synopsis_el) or None + + badge = self._text(soup.select_one(details_cfg["episode_badge"])) + badge_match = _NUMBER_RE.search(badge) + + season = 1 + for li in soup.select(details_cfg["season_li"]): + season_match = _SEASON_RE.search(self._text(li)) + if season_match: + season = int(season_match.group(1)) + break + + episodes = self._parse_episodes(soup, url, season) + + return TitleDetails( + source=self.name, + source_id=source_id, + title=self._text(title_el), + url=url, + synopsis=synopsis, + image_url=(soup.select_one(details_cfg["poster"]) or Tag(name="img")).get("src"), + genres=[a.get_text(strip=True) for a in soup.select(details_cfg["genres"])], + episode_count=int(badge_match.group(1).replace(",", ".")) if badge_match else None, + episodes=episodes, + ) + + # ------------------------------------------------------------ episodes + + async def list_episodes(self, source_id: str) -> list[Episode]: + url = self._title_url(source_id) + soup = await fetch_soup(url) + season = self._find_season(soup) + episodes = self._parse_episodes(soup, url, season) + if not episodes: + raise ScrapeError(f"vostfree : aucun épisode trouvé pour {source_id} ({url})") + return episodes + + def _find_season(self, soup: BeautifulSoup) -> int: + config = _merged_config() + for li in soup.select(config["details"]["season_li"]): + season_match = _SEASON_RE.search(self._text(li)) + if season_match: + return int(season_match.group(1)) + return 1 + + def _parse_episodes(self, soup: BeautifulSoup, page_url: str, season: int) -> list[Episode]: + config = _merged_config() + options = soup.select(config["episodes"]["option"]) + entries: list[tuple[str, str]] = [] + seen_ids: set[str] = set() + for opt in options: + value = opt.get("value", "") + if value in seen_ids: + continue # le site duplique parfois une option (ex. buttons_268) + seen_ids.add(value) + entries.append((value, opt.get_text(strip=True))) + if not entries: # fiche sans sélecteur : on retombe sur les blocs de boutons + entries = [ + (box.get("id", ""), f"Episode {index}") + for index, box in enumerate(soup.select(config["episodes"]["button"]), start=1) + ] + episodes: list[Episode] = [] + for index, (button_id, label) in enumerate(entries, start=1): + number_match = _NUMBER_RE.search(label) + number = ( + float(number_match.group(1).replace(",", ".")) if number_match else float(index) + ) + episodes.append( + Episode( + number=number, + title=label or f"Episode {index}", + url=f"{page_url}#{button_id}" if button_id else page_url, + season=season, + ) + ) + return episodes + + # -------------------------------------------------------------- embeds + + async def extract_embed_links(self, episode_url: str) -> list[str]: + config = _merged_config() + page_url, _, fragment = episode_url.partition("#") + soup = await fetch_soup(page_url) + button_cfg = config["episodes"] + + button: Tag | None = None + if fragment and _SAFE_FRAGMENT_RE.match(fragment): + button = soup.select_one(f"#{fragment}") + if button is None: + logger.warning( + "vostfree : fragment #%s sans bloc correspondant dans %s", fragment, page_url + ) + if button is None: + button = soup.select_one(button_cfg["button"]) + if button is None: + raise ScrapeError(f"vostfree : aucun lecteur trouvé sur {episode_url}") + + prefix = button_cfg["content_prefix"] + links: list[str] = [] + for player in button.find_all("div", recursive=False): + player_id = player.get("id") + content = soup.select_one(f"#{prefix}{player_id}") if player_id else None + if content is None: + logger.warning( + "vostfree : contenu absent pour le lecteur #%s (%s)", player_id, page_url + ) + continue + value = content.get_text(strip=True) + if not value: + continue + player_class = (player.get("class") or [""])[0] + template = config["players"].get(player_class) + if value.startswith("http"): + links.append(value) + elif template is not None: + links.append(template.format(value)) + else: + logger.warning( + "vostfree : pas de template pour le lecteur %r (valeur %r)", player_class, value + ) + if not links: + raise ScrapeError(f"vostfree : aucun lien embed extrait de {episode_url}") + logger.info("vostfree : %d liens embed pour %s", len(links), episode_url) + return links diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/discover.py b/app/services/discover.py new file mode 100644 index 0000000..8b7484b --- /dev/null +++ b/app/services/discover.py @@ -0,0 +1,352 @@ +"""Découverte : nouveautés des sources, incontournables et recommandations. + +Trois sections : +- **latest** — « récemment ajoutés » scrapés sur chaque source activée (cliquables + directement vers la fiche) ; +- **must_watch** — titres les plus populaires du catalogue Kitsu (tous temps) ; +- **for_you** — recommandations par genres : les genres des titres téléchargés + (serveur), des favoris (par utilisateur) et des séries téléchargées sur Sonarr + sont agrégés, puis Kitsu est interrogé sur ces catégories en excluant le + déjà-possédé. + +Toutes les sources externes sont optionnelles : un échec (réseau, scraping, API) +laisse la section vide et n'est jamais remonté au caller (dégradation gracieuse). +Un cache mémoire TTL évite de re-scaper à chaque chargement de page. +""" + +import asyncio +import dataclasses +import json +import logging +import re +import time +import unicodedata + +import httpx + +from app.config import get_settings +from app.db import db +from app.scrapers.base import ( + ScrapeError, + SourceScraper, + all_sources, + import_all_scrapers, +) +from app.services.kitsu import KitsuService, normalize_title +from app.services.settings import is_source_enabled +from app.services.sonarr import sonarr + +logger = logging.getLogger(__name__) + +import_all_scrapers() + +# Bornes de l'algorithme +_MAX_HISTORY_TITLES = 12 # titres récents analysés (téléchargements + favoris) +_MAX_GENRES = 4 # genres retenus pour la requête Kitsu +_KITSU_PAGE_MAX = 20 # limite dure de l'API Kitsu (page[limit] > 20 → 400) +_ENRICH_CONCURRENCY = 6 # enrichissements Kitsu parallèles max (nouveautés) + +_LATEST_TTL_SECONDS = 600 # nouveautés : re-scrape au bout de 10 min +_MUST_WATCH_TTL_SECONDS = 21600 # incontournables : quasi statique, 6 h +_FOR_YOU_TTL_SECONDS = 3600 # recommandations : 1 h (l'historique évolue lentement) + + +class _TTLCache: + """Cache mémoire minimal avec expiration (mono-processus, suffisant ici).""" + + def __init__(self) -> None: + self._data: dict[str, tuple[float, object]] = {} + + def get(self, key: str) -> object | None: + entry = self._data.get(key) + if entry is None: + return None + expires_at, value = entry + if expires_at <= time.monotonic(): + del self._data[key] + return None + return value + + def set(self, key: str, value: object, ttl_seconds: float) -> None: + self._data[key] = (time.monotonic() + ttl_seconds, value) + + def clear(self, prefix: str = "") -> None: + """Invalide les clés commençant par prefix (vide = tout le cache).""" + for key in [k for k in self._data if k.startswith(prefix)]: + del self._data[key] + + +def category_slug(name: str) -> str: + """Nom de genre → slug de catégorie Kitsu (« Slice of Life » → « slice-of-life »).""" + decomposed = unicodedata.normalize("NFKD", name) + ascii_only = "".join(char for char in decomposed if not unicodedata.combining(char)) + return re.sub(r"[^a-z0-9]+", "-", ascii_only.casefold()).strip("-") + + +class DiscoverService: + """Agrégation des trois sections de découverte, avec cache mémoire.""" + + def __init__(self) -> None: + self._cache = _TTLCache() + self._kitsu = KitsuService() + + # ------------------------------------------------------------ nouveautés + + async def latest(self, limit: int = 24) -> list[dict]: + """Nouveautés toutes sources confondues, triées par date de sortie réelle. + + Les « récemment ajoutés » de chaque source sont fusionnés (doublons retirés), + enrichis via Kitsu (date de début, statut de diffusion) puis triés du plus + récent au plus ancien — ce qui sort / vient de sortir en premier. + """ + cached = self._cache.get(f"latest:{limit}") + if cached is not None: + return cached # type: ignore[return-value] + + sources = [s for s in all_sources() if await is_source_enabled(s.name)] + outcomes = await asyncio.gather(*(self._latest_of(source) for source in sources)) + items = [item for outcome in outcomes for item in (outcome or [])] + + merged: dict[str, dict] = {} + for item in items: + key = normalize_title(item["title"]).casefold() + existing = merged.get(key) + if existing is None or (not existing.get("image_url") and item.get("image_url")): + merged[key] = item + semaphore = asyncio.Semaphore(_ENRICH_CONCURRENCY) + + async def bounded(item: dict) -> dict: + async with semaphore: + return await self._with_release_info(item) + + enriched = await asyncio.gather(*(bounded(item) for item in merged.values())) + result = sorted(enriched, key=lambda it: it.get("start_date") or "", reverse=True) + result = result[:limit] + self._cache.set(f"latest:{limit}", result, _LATEST_TTL_SECONDS) + return result + + async def _latest_of(self, source: SourceScraper) -> list[dict] | None: + """Items latest() d'une source, aplatis avec les infos de source ([] si KO).""" + try: + results = await source.latest() + except ScrapeError as exc: + logger.warning("Nouveautés indisponibles pour %s : %s", source.name, exc) + return None + return [ + {**dataclasses.asdict(r), "source": source.name, "label": source.label} + for r in results + ] + + async def _with_release_info(self, item: dict) -> dict: + """Complète un item de nouveauté avec sa date de sortie Kitsu (None si absent).""" + item.setdefault("start_date", None) + item.setdefault("status", None) + item.setdefault("rating", None) + match = await self._kitsu_match_for_title(item["title"]) + if match is None: + return item + attrs = match.get("attributes", {}) + item["start_date"] = attrs.get("startDate") + item["status"] = attrs.get("status") + item["rating"] = KitsuService._to_rating_10(attrs.get("averageRating")) + return item + + # --------------------------------------------------------- incontournables + + async def must_watch(self, limit: int = _KITSU_PAGE_MAX) -> list[dict]: + """Titres les plus populaires du catalogue Kitsu (tous temps).""" + limit = min(limit, _KITSU_PAGE_MAX) + key = f"must_watch:{limit}" + cached = self._cache.get(key) + if cached is not None: + return cached # type: ignore[return-value] + items = await self._kitsu_anime({"sort": "-userCount", "page[limit]": limit}) + self._cache.set(key, items, _MUST_WATCH_TTL_SECONDS) + return items + + # ------------------------------------------------------------ pour toi + + async def for_you(self, user_id: int, limit: int = _KITSU_PAGE_MAX) -> dict: + """Recommandations par genres, à partir de l'historique de l'utilisateur. + + Genres = téléchargements du serveur (titres → Kitsu) + favoris de l'utilisateur + (genres du payload) + séries téléchargées sur Sonarr (genres fournis par + Sonarr). On exclut les titres déjà possédés (local et Sonarr). + """ + limit = min(limit, _KITSU_PAGE_MAX) + cache_key = f"for_you:{user_id}:{limit}" + cached = self._cache.get(cache_key) + if cached is not None: + return cached # type: ignore[return-value] + + owned, favorite_genres = await self._owned(user_id) + genre_counts = await self._genres_from_downloads(owned) + for genre, count in favorite_genres.items(): + genre_counts[genre] = genre_counts.get(genre, 0) + count + sonarr_owned, sonarr_genres = await sonarr.profile() + for genre, count in sonarr_genres.items(): + genre_counts[genre] = genre_counts.get(genre, 0) + count + owned |= sonarr_owned + if not genre_counts: + result: dict = {"based_on": [], "items": []} + self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS) + return result + + top_genres = sorted(genre_counts, key=genre_counts.get, reverse=True)[:_MAX_GENRES] + slugs = [category_slug(genre) for genre in top_genres] + items = await self._kitsu_anime( + { + "filter[categories]": ",".join(slugs), + "sort": "-userCount", + "page[limit]": limit, # le déjà-possédé est filtré après + } + ) + kept = [item for item in items if item["title"] and item["title"].casefold() not in owned] + result = {"based_on": top_genres, "items": kept} + self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS) + return result + + def invalidate_for_you(self) -> None: + """Recommandations recalculées au prochain appel (réglages Sonarr modifiés).""" + self._cache.clear("for_you:") + + async def _owned(self, user_id: int) -> tuple[set[str], dict[str, int]]: + """Titres possédés (normalisés) + genres directement connus via les favoris.""" + rows = await db.fetchall( + "SELECT DISTINCT title FROM downloads ORDER BY created_at DESC LIMIT ?", + (_MAX_HISTORY_TITLES,), + ) + fav_rows = await db.fetchall( + "SELECT payload FROM favorites WHERE user_id = ? ORDER BY created_at DESC LIMIT ?", + (user_id, _MAX_HISTORY_TITLES), + ) + owned = {normalize_title(row["title"]).casefold() for row in rows} + owned.discard("") + genre_counts: dict[str, int] = {} + for row in fav_rows: + try: + payload = json.loads(row["payload"]) if row["payload"] else {} + except (TypeError, ValueError): + continue + for genre in payload.get("genres") or []: + if isinstance(genre, str) and genre.strip(): + genre_counts[genre.strip()] = genre_counts.get(genre.strip(), 0) + 1 + return owned, genre_counts + + async def _genres_from_downloads(self, titles: set[str]) -> dict[str, int]: + """Genres Kitsu des titres téléchargés (cache DB puis recherche).""" + semaphore = asyncio.Semaphore(_MAX_HISTORY_TITLES) + + async def genres_of(title: str) -> list[str]: + async with semaphore: + return await self._kitsu_genres_for_title(title) + + outcomes = await asyncio.gather(*(genres_of(t) for t in list(titles)[:_MAX_HISTORY_TITLES])) + counts: dict[str, int] = {} + for genres in outcomes: + for genre in genres: + counts[genre] = counts.get(genre, 0) + 1 + return counts + + async def _kitsu_match_for_title(self, title: str) -> dict | None: + """Match Kitsu d'un titre scrapé (cache DB 72 h via metadata_cache).""" + query = normalize_title(title) + if not query: + return None + cache_key = f"kitsu:anime:{query.casefold()}" + match = await self._kitsu.get_cached(cache_key) + if match is None: + match = await self._kitsu.search_anime(title) + if match is not None: + await self._kitsu.set_cached(cache_key, match) + return match + + async def _kitsu_genres_for_title(self, title: str) -> list[str]: + """Genres Kitsu d'un titre scrapé (cache DB 72 h via metadata_cache). + + La recherche Kitsu ne renvoie plus les genres (`include=genres` vide) : + on complète avec l'endpoint /anime//categories. + """ + match = await self._kitsu_match_for_title(title) + if match is None: + return [] + genres = [g for g in match.get("genres", []) if isinstance(g, str)] + if not genres: + genres = await self._kitsu_categories(match.get("id")) + match["genres"] = genres + query = normalize_title(title) + await self._kitsu.set_cached( # refresh avec les genres + f"kitsu:anime:{query.casefold()}", match + ) + return genres + + # -------------------------------------------------------------- Kitsu + + async def _kitsu_anime(self, params: dict) -> list[dict]: + """Requête générique liste Kitsu → items normalisés ([] si échec).""" + settings = get_settings() + try: + async with httpx.AsyncClient( + timeout=settings.http_timeout, + headers={ + "User-Agent": settings.user_agent, + "Accept": "application/vnd.api+json", + }, + ) as client: + response = await client.get(f"{settings.kitsu_base_url}/anime", params=params) + response.raise_for_status() + payload = response.json() + except (httpx.HTTPError, ValueError) as exc: + logger.warning("Liste Kitsu échouée (%s) : %s", params, exc) + return [] + return [ + self._normalize_anime(item) + for item in payload.get("data", []) + if item.get("type") == "anime" + ] + + async def _kitsu_categories(self, anime_id: object) -> list[str]: + """Titres des catégories Kitsu d'un anime ([] si échec).""" + if not anime_id: + return [] + settings = get_settings() + try: + async with httpx.AsyncClient( + timeout=settings.http_timeout, + headers={ + "User-Agent": settings.user_agent, + "Accept": "application/vnd.api+json", + }, + ) as client: + response = await client.get( + f"{settings.kitsu_base_url}/anime/{anime_id}/categories", + params={"page[limit]": _KITSU_PAGE_MAX}, + ) + response.raise_for_status() + payload = response.json() + except (httpx.HTTPError, ValueError) as exc: + logger.warning("Catégories Kitsu échouées (anime %s) : %s", anime_id, exc) + return [] + return [ + attrs["title"] + for item in payload.get("data", []) + if isinstance(attrs := item.get("attributes", {}), dict) and attrs.get("title") + ] + + @staticmethod + def _normalize_anime(item: dict) -> dict: + attrs = item.get("attributes", {}) + titles = attrs.get("titles") or {} + poster = attrs.get("posterImage") or {} + return { + "kitsu_id": item.get("id"), + "title": attrs.get("canonicalTitle") or titles.get("en_jp"), + "image_url": poster.get("large") or poster.get("medium") or poster.get("tiny"), + "rating": KitsuService._to_rating_10(attrs.get("averageRating")), + "year": KitsuService._extract_year(attrs.get("startDate")), + "subtype": attrs.get("subtype"), + "user_count": attrs.get("userCount"), + } + + +discover = DiscoverService() diff --git a/app/services/downloads.py b/app/services/downloads.py new file mode 100644 index 0000000..92a3c5e --- /dev/null +++ b/app/services/downloads.py @@ -0,0 +1,499 @@ +"""Gestionnaire de téléchargements : file asyncio, parallélisme limité, +pause/reprise (Range HTTP), anti-doublons, persistance, progression temps réel. + +Les statuts : pending → downloading → done | failed | cancelled + ↕ paused +""" + +import asyncio +import contextlib +import logging +import re +import signal +import time +import unicodedata +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any +from urllib.parse import urljoin + +import httpx + +from app.config import get_settings +from app.db import db + +logger = logging.getLogger(__name__) + +ACTIVE_STATUSES = ("pending", "downloading", "paused") + +_FFMPEG_TIME_RE = re.compile(r"time=(\d+:\d+:\d+(?:\.\d+)?)") +_EXTINF_RE = re.compile(r"#EXTINF:([\d.]+)") +_BANDWIDTH_RE = re.compile(r"#EXT-X-STREAM-INF:[^\n]*BANDWIDTH=(\d+)[^\n]*\n(\S+)") + + +def _parse_ffmpeg_time(value: str) -> float: + parts = value.split(":") + return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2]) + + +def _best_variant(master_body: str, base_url: str) -> str | None: + """URL de la variante au plus haut débit d'une playlist maître HLS.""" + variants = [ + (int(bw), urljoin(base_url, uri)) for bw, uri in _BANDWIDTH_RE.findall(master_body) + ] + return max(variants)[1] if variants else None + +_STATUS_LABELS = { + "pending": "en attente", + "downloading": "en cours", + "paused": "en pause", + "done": "terminé", + "failed": "échec", + "cancelled": "annulé", +} + + +def sanitize_filename(name: str) -> str: + """Nettoie un nom de fichier : caractères interdits retirés, anti-traversée.""" + name = unicodedata.normalize("NFKC", name) + name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", name) + name = re.sub(r"\s+", " ", name).strip(" .") + if not name or name in (".", ".."): + name = "video" + return name[:150] + + +class DownloadManager: + """File d'attente de téléchargements, injectée dans les routes via app.state.""" + + def __init__(self) -> None: + self._queue: asyncio.Queue[int] # créée dans start() (affinité avec la boucle) + self._tasks: dict[int, asyncio.Task] = {} # download_id → tâche asyncio + self._pause_events: dict[int, asyncio.Event] = {} # set = peut tourner + self._progress: dict[int, dict[str, Any]] = {} # progression temps réel en mémoire + self._listeners: list[asyncio.Queue] = [] + self._workers: list[asyncio.Task] = [] + self._client: httpx.AsyncClient | None = None + self._hls_processes: dict[int, asyncio.subprocess.Process] = {} + + # ------------------------------------------------------------ cycle de vie + + async def start(self) -> None: + self._queue = asyncio.Queue() + settings = get_settings() + self._client = httpx.AsyncClient( + timeout=httpx.Timeout(30.0, read=300.0), + follow_redirects=True, + headers={ + "User-Agent": settings.user_agent, + "Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8", + }, + ) + # Restaure les téléchargements interrompus (crash/arrêt) en 'pending' + await db.execute( + "UPDATE downloads SET status = 'pending', updated_at = datetime('now') " + "WHERE status = 'downloading'" + ) + await self._scan_download_dir() + for _ in range(settings.max_parallel_downloads): + self._workers.append(asyncio.create_task(self._worker())) + logger.info("DownloadManager démarré (%d workers)", settings.max_parallel_downloads) + + async def stop(self) -> None: + for worker in self._workers: + worker.cancel() + for task in self._tasks.values(): + task.cancel() + for proc in self._hls_processes.values(): + if proc.returncode is None: + proc.kill() + if self._client: + await self._client.aclose() + self._workers.clear() + self._tasks.clear() + + async def _scan_download_dir(self) -> None: + """Restaure en 'done' les fichiers présents sur disque mais inconnus de la DB.""" + download_dir = get_settings().download_dir + rows = await db.fetchall("SELECT file_path FROM downloads WHERE file_path IS NOT NULL") + known = {row["file_path"] for row in rows} + for path in download_dir.iterdir(): + if path.is_file() and path.suffix != ".part" and path.name not in known: + size = path.stat().st_size + await db.execute( + "INSERT INTO downloads " + "(source_key, video_url, page_url, title, file_path, status, " + " total_bytes, downloaded_bytes) " + "VALUES (?, ?, ?, ?, ?, 'done', ?, ?)", + ( + f"file:{path.name}", + "", + "", + path.stem, + path.name, + size, + size, + ), + ) + logger.info("Fichier restauré depuis le disque : %s", path.name) + + # ------------------------------------------------------------ API publique + + async def enqueue(self, video_url: str, page_url: str, title: str) -> dict: + """Ajoute un téléchargement ; retourne la tâche existante si doublon actif.""" + source_key = video_url + existing = await db.fetchone( + f"SELECT * FROM downloads WHERE source_key = ? AND status IN " + f"({','.join('?' * len(ACTIVE_STATUSES))})", + (source_key, *ACTIVE_STATUSES), + ) + if existing: + logger.info("Anti-doublon : %s déjà en file (id=%s)", title, existing["id"]) + return self._to_dict(existing, duplicate=True) + + filename = sanitize_filename(title) + self._guess_extension(video_url) + cursor = await db.execute( + "INSERT INTO downloads (source_key, video_url, page_url, title, file_path) " + "VALUES (?, ?, ?, ?, ?)", + (source_key, video_url, page_url, title, filename), + ) + download_id = cursor.lastrowid + await self._queue.put(download_id) + await self._emit(download_id) + logger.info("Téléchargement ajouté : %s (id=%s)", title, download_id) + row = await db.fetchone("SELECT * FROM downloads WHERE id = ?", (download_id,)) + return self._to_dict(row) + + async def pause(self, download_id: int) -> bool: + event = self._pause_events.get(download_id) + if event: + event.clear() + await self._set_status(download_id, "paused") + return True + + async def resume(self, download_id: int) -> bool: + row = await self._get_row(download_id) + if row["status"] != "paused": + return False + await self._set_status(download_id, "pending") + await self._queue.put(download_id) + return True + + async def retry(self, download_id: int) -> bool: + row = await self._get_row(download_id) + if row["status"] not in ("failed", "cancelled"): + return False + await db.execute( + "UPDATE downloads SET status = 'pending', error = NULL, downloaded_bytes = 0, " + "updated_at = datetime('now') WHERE id = ?", + (download_id,), + ) + part = self._part_path(row["file_path"]) + part.unlink(missing_ok=True) + await self._queue.put(download_id) + await self._emit(download_id) + return True + + async def cancel(self, download_id: int) -> bool: + task = self._tasks.get(download_id) + if task: + task.cancel() + proc = self._hls_processes.get(download_id) + if proc and proc.returncode is None: + proc.kill() + await self._set_status(download_id, "cancelled") + row = await self._get_row(download_id) + self._part_path(row["file_path"]).unlink(missing_ok=True) + await self._emit(download_id) + return True + + async def cancel_all(self) -> int: + rows = await db.fetchall( + f"SELECT id FROM downloads WHERE status IN ({','.join('?' * len(ACTIVE_STATUSES))})", + ACTIVE_STATUSES, + ) + for row in rows: + await self.cancel(row["id"]) + return len(rows) + + async def clear_finished(self) -> int: + """Supprime de la file les tâches terminées/échouées/annulées (fichiers gardés).""" + cursor = await db.execute( + "DELETE FROM downloads WHERE status IN ('done', 'failed', 'cancelled')" + ) + return cursor.rowcount or 0 + + async def list_all(self, limit: int = 200) -> list[dict]: + rows = await db.fetchall( + "SELECT * FROM downloads ORDER BY " + "CASE status WHEN 'downloading' THEN 0 WHEN 'pending' THEN 1 WHEN 'paused' THEN 2 " + "ELSE 3 END, updated_at DESC LIMIT ?", + (limit,), + ) + return [self._to_dict(row) for row in rows] + + async def get(self, download_id: int) -> dict | None: + row = await db.fetchone("SELECT * FROM downloads WHERE id = ?", (download_id,)) + return self._to_dict(row) if row else None + + # ------------------------------------------------------------ événements SSE + + async def subscribe(self) -> AsyncIterator[dict]: + queue: asyncio.Queue = asyncio.Queue(maxsize=100) + self._listeners.append(queue) + try: + while True: + yield await queue.get() + finally: + self._listeners.remove(queue) + + async def _emit(self, download_id: int) -> None: + data = await self.get(download_id) + if data is None: + return + for queue in self._listeners: + with contextlib.suppress(asyncio.QueueFull): + queue.put_nowait(data) + + # ------------------------------------------------------------ worker interne + + async def _worker(self) -> None: + while True: + download_id = await self._queue.get() + row = await db.fetchone("SELECT status FROM downloads WHERE id = ?", (download_id,)) + if row is None or row["status"] != "pending": + continue # annulé/pausé entre-temps + # Tâche dédiée : annuler un téléchargement ne doit pas tuer le worker + task = asyncio.create_task(self._download(download_id)) + self._tasks[download_id] = task + self._pause_events[download_id] = asyncio.Event() + self._pause_events[download_id].set() + try: + await task + except asyncio.CancelledError: + if asyncio.current_task().cancelling() > 0: + raise # le worker lui-même s'arrête (stop()) + finally: + self._tasks.pop(download_id, None) + self._pause_events.pop(download_id, None) + self._progress.pop(download_id, None) + + async def _download(self, download_id: int) -> None: + """Dispatche HTTP/HLS ; gestion d'erreurs centralisée ici.""" + row = await self._get_row(download_id) + try: + if ".m3u8" in row["video_url"]: + await self._download_hls(download_id, row) + else: + await self._download_http(download_id, row) + except asyncio.CancelledError: + logger.info("Téléchargement annulé : %s", row["file_path"]) + raise + except (httpx.HTTPError, OSError) as exc: + # Échec réseau/disque : journalisé, statut 'failed' visible dans l'UI + logger.error("Échec du téléchargement de %s : %s", row["file_path"], exc) + part = self._part_path(row["file_path"]) + downloaded = part.stat().st_size if part.exists() else 0 + await self._fail(download_id, exc, downloaded) + + async def _download_http(self, download_id: int, row: Any) -> None: + """Téléchargement HTTP direct avec reprise via Range.""" + video_url, file_path = row["video_url"], row["file_path"] + target = get_settings().download_dir / file_path + part = self._part_path(file_path) + downloaded = part.stat().st_size if part.exists() else 0 + + headers: dict[str, str] = {} + if row["page_url"]: + headers["Referer"] = row["page_url"] + if downloaded: + headers["Range"] = f"bytes={downloaded}-" + logger.info("Reprise de %s à %d octets", file_path, downloaded) + + await self._set_status(download_id, "downloading") + await self._emit(download_id) + started = time.monotonic() + last_emit = 0.0 + + async with self._client.stream("GET", video_url, headers=headers) as response: + if response.status_code == 416: # plage invalide → déjà complet + part.rename(target) + await self._finish(download_id, downloaded) + return + response.raise_for_status() + if downloaded and response.status_code != 206: + downloaded = 0 # serveur sans support Range → on repart de zéro + logger.warning("Pas de reprise possible pour %s", file_path) + total = int(response.headers.get("content-length") or 0) + downloaded or None + await db.execute( + "UPDATE downloads SET total_bytes = ? WHERE id = ?", (total, download_id) + ) + + mode = "ab" if downloaded else "wb" + with part.open(mode) as fh: + async for chunk in response.aiter_bytes(1 << 16): + event = self._pause_events.get(download_id) + if event is not None: + await event.wait() # pause coopérative + fh.write(chunk) + downloaded += len(chunk) + now = time.monotonic() + if now - last_emit >= 1.0: + last_emit = now + await self._report_progress(download_id, downloaded, total, started) + + part.rename(target) + await self._finish(download_id, downloaded) + + async def _download_hls(self, download_id: int, row: Any) -> None: + """Télécharge un flux HLS (.m3u8) via ffmpeg (remux en mp4). + + Pause via SIGSTOP/SIGCONT du processus, annulation via kill. + La progression est estimée depuis la durée totale de la playlist. + """ + video_url, file_path = row["video_url"], row["file_path"] + target = get_settings().download_dir / file_path + part = self._part_path(file_path) + part.unlink(missing_ok=True) # pas de reprise partielle en HLS + + ffmpeg_headers = f"User-Agent: {get_settings().user_agent}\r\n" + if row["page_url"]: + ffmpeg_headers += f"Referer: {row['page_url']}\r\n" + ffmpeg_headers += "Accept-Language: fr-FR,fr;q=0.9,en;q=0.8\r\n" + + total_seconds = await self._hls_duration(video_url, row["page_url"]) + + await self._set_status(download_id, "downloading") + await self._emit(download_id) + started = time.monotonic() + + process = await asyncio.create_subprocess_exec( + "ffmpeg", "-y", "-nostdin", "-v", "error", "-nostats", "-progress", "pipe:2", + "-headers", ffmpeg_headers, + "-i", video_url, + "-c", "copy", "-bsf:a", "aac_adtstoasc", "-f", "mp4", + str(part), + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + self._hls_processes[download_id] = process + try: + assert process.stderr is not None + async for raw_line in process.stderr: + event = self._pause_events.get(download_id) + if event is not None and not event.is_set(): + process.send_signal(signal.SIGSTOP) + await event.wait() + process.send_signal(signal.SIGCONT) + line = raw_line.decode(errors="replace") + if match := _FFMPEG_TIME_RE.search(line): + elapsed_video = _parse_ffmpeg_time(match.group(1)) + size = part.stat().st_size if part.exists() else 0 + total_est = None + if total_seconds and elapsed_video > 0: + total_est = int(size / elapsed_video * total_seconds) + await self._report_progress(download_id, size, total_est, started) + return_code = await process.wait() + finally: + self._hls_processes.pop(download_id, None) + + if return_code != 0: + part.unlink(missing_ok=True) + raise OSError(f"ffmpeg a échoué (code {return_code}) sur le flux HLS") + size = part.stat().st_size + part.rename(target) + await self._finish(download_id, size) + + async def _hls_duration(self, playlist_url: str, referer: str | None) -> float | None: + """Durée totale d'une playlist HLS (somme des EXTINF de la variante max).""" + headers = {"Referer": referer} if referer else {} + try: + response = await self._client.get(playlist_url, headers=headers) + response.raise_for_status() + body = response.text + # Playlist maître → on suit la variante de plus haut débit + variant = _best_variant(body, playlist_url) + if variant and variant != playlist_url: + response = await self._client.get(variant, headers=headers) + response.raise_for_status() + body = response.text + durations = [float(m) for m in _EXTINF_RE.findall(body)] + return sum(durations) if durations else None + except (httpx.HTTPError, ValueError) as exc: + logger.warning("Durée HLS indéterminée pour %s : %s", playlist_url, exc) + return None + + async def _report_progress( + self, download_id: int, downloaded: int, total: int | None, started: float + ) -> None: + elapsed = time.monotonic() - started + self._progress[download_id] = { + "downloaded_bytes": downloaded, + "total_bytes": total, + "speed_bps": int(downloaded / elapsed) if elapsed > 0 else 0, + } + await self._emit(download_id) + + async def _fail(self, download_id: int, exc: Exception, downloaded: int = 0) -> None: + await db.execute( + "UPDATE downloads SET status = 'failed', error = ?, " + "downloaded_bytes = ?, updated_at = datetime('now') WHERE id = ?", + (str(exc)[:500], downloaded, download_id), + ) + await self._emit(download_id) + + async def _finish(self, download_id: int, size: int) -> None: + await db.execute( + "UPDATE downloads SET status = 'done', total_bytes = ?, downloaded_bytes = ?, " + "updated_at = datetime('now') WHERE id = ?", + (size, size, download_id), + ) + await self._emit(download_id) + logger.info("Téléchargement terminé (id=%s, %d octets)", download_id, size) + + # ------------------------------------------------------------ helpers + + async def _get_row(self, download_id: int) -> Any: + row = await db.fetchone("SELECT * FROM downloads WHERE id = ?", (download_id,)) + if row is None: + raise KeyError(f"Téléchargement introuvable : {download_id}") + return row + + async def _set_status(self, download_id: int, status: str) -> None: + await db.execute( + "UPDATE downloads SET status = ?, updated_at = datetime('now') WHERE id = ?", + (status, download_id), + ) + + @staticmethod + def _part_path(file_path: str | None) -> Path: + name = file_path or "video" + return get_settings().download_dir / (name + ".part") + + @staticmethod + def _guess_extension(url: str) -> str: + match = re.search(r"\.(mp4|mkv|webm|avi|m3u8)(?:\?|$)", url) + ext = match.group(1) if match else "mp4" + return ".mp4" if ext == "m3u8" else f".{ext}" + + def _to_dict(self, row: Any, duplicate: bool = False) -> dict: + data = dict(row) + live = self._progress.get(data["id"], {}) + downloaded = live.get("downloaded_bytes", data["downloaded_bytes"]) + total = live.get("total_bytes", data["total_bytes"]) + speed = live.get("speed_bps", 0) + percent = round(downloaded / total * 100, 1) if total else None + eta = int((total - downloaded) / speed) if total and speed else None + data.update( + downloaded_bytes=downloaded, + total_bytes=total, + percent=percent, + speed_bps=speed, + eta_seconds=eta, + status_label=_STATUS_LABELS.get(data["status"], data["status"]), + duplicate=duplicate, + ) + return data + + +download_manager = DownloadManager() diff --git a/app/services/kitsu.py b/app/services/kitsu.py new file mode 100644 index 0000000..15af465 --- /dev/null +++ b/app/services/kitsu.py @@ -0,0 +1,198 @@ +"""Enrichissement de métadonnées via l'API Kitsu (https://kitsu.io/api/edge). + +Fusion intelligente : seuls les champs manquants d'un TitleDetails sont complétés. +Les échecs (réseau, cache indisponible) sont loggés en warning et jamais remontés +au caller — la fiche d'origine est retournée inchangée (dégradation gracieuse). +""" + +import json +import logging +import re +import sqlite3 + +import httpx + +from app.config import get_settings +from app.db import db +from app.scrapers.base import TitleDetails + +logger = logging.getLogger(__name__) + +_NOISE_WORDS_RE = re.compile( + r"\b(?:VOSTFR\d*|VOST|VF[IV]?|TRUEFRENCH|FRENCH|MULTI|SUBFR?)\b", re.IGNORECASE +) +_TRAILING_SEASON_RE = re.compile(r"[\s\-–—:.]*\s*(?:saison|season)\s*\d+\s*$", re.IGNORECASE) +_TRAILING_CODE_RE = re.compile(r"[\s\-–—:.]*\s*S\d+(?:E\d+)?\s*$", re.IGNORECASE) + + +def normalize_title(title: str) -> str: + """Nettoie un titre de scraping avant recherche Kitsu (bruit, saison, tirets).""" + cleaned = _NOISE_WORDS_RE.sub(" ", title) + cleaned = _TRAILING_SEASON_RE.sub("", cleaned) + cleaned = _TRAILING_CODE_RE.sub("", cleaned) + cleaned = re.sub(r"\s*[-–—_]+\s*", " ", cleaned) + cleaned = re.sub(r"\s+", " ", cleaned) + return cleaned.strip(" -–—:.") + + +class KitsuService: + """Recherche et cache des métadonnées anime depuis l'API Kitsu.""" + + async def search_anime(self, title: str) -> dict | None: + """Recherche le meilleur match Kitsu pour un titre (None si échec/rien trouvé).""" + settings = get_settings() + query = normalize_title(title) + if not query: + return None + try: + async with httpx.AsyncClient( + timeout=settings.http_timeout, + headers={ + "User-Agent": settings.user_agent, + "Accept": "application/vnd.api+json", + }, + ) as client: + response = await client.get( + f"{settings.kitsu_base_url}/anime", + params={"filter[text]": query, "page[limit]": 5, "include": "genres"}, + ) + response.raise_for_status() + payload = response.json() + except (httpx.HTTPError, ValueError) as exc: + logger.warning("Recherche Kitsu échouée pour %r : %s", title, exc) + return None + return self._pick_best_match(payload, query) + + async def enrich(self, details: TitleDetails) -> TitleDetails: + """Complète les champs manquants de details via Kitsu (jamais episodes/url/source).""" + query = normalize_title(details.title) + cache_key = f"kitsu:anime:{query.casefold()}" + + result = await self.get_cached(cache_key) + from_cache = result is not None + if result is None: + result = await self.search_anime(details.title) + if result is not None: + await self.set_cached(cache_key, result) + + if result is None: + logger.info("Aucune métadonnée Kitsu pour %r — fiche inchangée", details.title) + return details + + attrs = result.get("attributes", {}) + if not details.synopsis: + details.synopsis = attrs.get("synopsis") + if not details.image_url: + poster = attrs.get("posterImage") or {} + details.image_url = poster.get("large") or poster.get("medium") + if not details.banner_url: + cover = attrs.get("coverImage") or {} + details.banner_url = cover.get("large") or cover.get("original") + if not details.genres: + details.genres = list(result.get("genres", [])) + if details.rating is None: + details.rating = self._to_rating_10(attrs.get("averageRating")) + if details.year is None: + details.year = self._extract_year(attrs.get("startDate")) + if details.episode_count is None: + details.episode_count = attrs.get("episodeCount") + + logger.debug( + "Fiche %r enrichie via Kitsu (cache=%s, id=%s)", + details.title, + from_cache, + result.get("id"), + ) + return details + + async def get_cached(self, cache_key: str) -> dict | None: + """Retourne le payload en cache s'il existe et est frais (TTL), sinon None.""" + ttl_hours = get_settings().metadata_cache_ttl_hours + try: + row = await db.fetchone( + "SELECT payload FROM metadata_cache " + f"WHERE cache_key = ? AND fetched_at > datetime('now', '-{ttl_hours} hours')", + (cache_key,), + ) + except (RuntimeError, sqlite3.Error) as exc: + logger.warning( + "Cache métadonnées illisible (%s), continuation sans cache : %s", cache_key, exc + ) + return None + if row is None: + return None + try: + return json.loads(row["payload"]) + except (TypeError, ValueError) as exc: + logger.warning("Cache métadonnées corrompu pour %s, re-fetch : %s", cache_key, exc) + return None + + async def set_cached(self, cache_key: str, payload: dict) -> None: + """Écrit/upsert une entrée de cache (échec non bloquant, warning seulement).""" + try: + await db.execute( + "INSERT INTO metadata_cache (cache_key, payload, fetched_at) " + "VALUES (?, ?, datetime('now')) " + "ON CONFLICT(cache_key) DO UPDATE SET " + "payload = excluded.payload, fetched_at = excluded.fetched_at", + (cache_key, json.dumps(payload, ensure_ascii=False)), + ) + except (RuntimeError, sqlite3.Error) as exc: + logger.warning( + "Cache métadonnées non écrit (%s), continuation sans cache : %s", cache_key, exc + ) + + @staticmethod + def _pick_best_match(payload: dict, query: str) -> dict | None: + items = [item for item in payload.get("data", []) if item.get("type") == "anime"] + if not items: + return None + + genre_names = { + obj.get("id"): obj.get("attributes", {}).get("name") + for obj in payload.get("included", []) + if obj.get("type") == "genres" + } + + def titles_of(item: dict) -> set[str]: + attrs = item.get("attributes", {}) + titles = attrs.get("titles") or {} + return { + t.strip().casefold() + for t in (attrs.get("canonicalTitle"), titles.get("en"), titles.get("en_jp")) + if t + } + + def popularity(item: dict) -> tuple[int, float]: + attrs = item.get("attributes", {}) + return ( + attrs.get("userCount") or 0, + float(attrs.get("averageRating") or 0), + ) + + best = next((item for item in items if query.casefold() in titles_of(item)), None) + if best is None: + best = max(items, key=popularity) + + genre_ids = best.get("relationships", {}).get("genres", {}).get("data") or [] + genres = [genre_names[g["id"]] for g in genre_ids if g.get("id") in genre_names] + return {"id": best.get("id"), "attributes": best.get("attributes", {}), "genres": genres} + + @staticmethod + def _to_rating_10(average_rating: str | None) -> float | None: + """Kitsu note sur 100 (chaîne) → note sur 10 arrondie à 1 décimale.""" + if average_rating is None: + return None + try: + return round(float(average_rating) / 10, 1) + except (TypeError, ValueError): + return None + + @staticmethod + def _extract_year(start_date: str | None) -> int | None: + if not start_date: + return None + try: + return int(str(start_date)[:4]) + except ValueError: + return None diff --git a/app/services/settings.py b/app/services/settings.py new file mode 100644 index 0000000..d22bdf8 --- /dev/null +++ b/app/services/settings.py @@ -0,0 +1,72 @@ +"""Paramètres persistés en DB (activation des sources, réglages UI…).""" +import json +import logging +import secrets + +from app.db import db + +logger = logging.getLogger(__name__) + + +async def get_setting(key: str, default: object = None) -> object: + row = await db.fetchone("SELECT value FROM settings WHERE key = ?", (key,)) + if row is None: + return default + try: + return json.loads(row["value"]) + except json.JSONDecodeError: + logger.warning("Paramètre %r corrompu, valeur par défaut utilisée", key) + return default + + +async def set_setting(key: str, value: object) -> None: + await db.execute( + "INSERT INTO settings (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, json.dumps(value, ensure_ascii=False)), + ) + + +async def is_source_enabled(name: str) -> bool: + return bool(await get_setting(f"source:{name}:enabled", True)) + + +async def set_source_enabled(name: str, enabled: bool) -> None: + await set_setting(f"source:{name}:enabled", enabled) + logger.info("Source %s %s", name, "activée" if enabled else "désactivée") + +# ---------------------------------------------------------------- intégrations *arr + +TORZNAB_APIKEY_KEY = "torznab:apikey" +SONARR_URL_KEY = "sonarr:url" +SONARR_APIKEY_KEY = "sonarr:apikey" + + +async def get_torznab_apikey() -> str: + """Clé API Torznab (générée au premier appel, persistée en DB).""" + key = await get_setting(TORZNAB_APIKEY_KEY) + if not isinstance(key, str) or not key: + key = secrets.token_hex(16) + await set_setting(TORZNAB_APIKEY_KEY, key) + logger.info("Clé API Torznab générée") + return key + + +async def reset_torznab_apikey() -> str: + key = secrets.token_hex(16) + await set_setting(TORZNAB_APIKEY_KEY, key) + logger.info("Clé API Torznab régénérée") + return key + + +async def get_sonarr_config() -> dict[str, str]: + return { + "url": await get_setting(SONARR_URL_KEY, ""), + "apikey": await get_setting(SONARR_APIKEY_KEY, ""), + } + + +async def set_sonarr_config(url: str, apikey: str) -> None: + await set_setting(SONARR_URL_KEY, url.rstrip("/")) + await set_setting(SONARR_APIKEY_KEY, apikey.strip()) + logger.info("Configuration Sonarr enregistrée (%s)", url) diff --git a/app/services/sonarr.py b/app/services/sonarr.py new file mode 100644 index 0000000..c83a18b --- /dev/null +++ b/app/services/sonarr.py @@ -0,0 +1,152 @@ +"""Client Sonarr (API v3) — personnalisation de « Pour toi ». + +OhmStreaming lit ce qui est téléchargé/grabé sur Sonarr : +- `/api/v3/history` (événements grab/import) → titres récemment obtenus ; +- `/api/v3/series` → genres de chaque série (fournis par Sonarr lui-même). + +Ces données alimentent le profil de genres des recommandations et la liste +d'exclusion (le déjà-possédé sur Sonarr n'est pas re-recommandé). + +Toujours tolérant aux pannes : Sonarr absent/non configuré → profil vide, +la découverte continue de fonctionner avec l'historique local. +""" + +import logging +import time + +import httpx + +from app.services.kitsu import normalize_title +from app.services.settings import get_sonarr_config + +logger = logging.getLogger(__name__) + +_HISTORY_PAGE_SIZE = 100 +_MAX_TITLES = 30 # titres Sonarr récents analysés +_PROFILE_TTL = 900.0 # profil Sonarr re-quantifié au bout de 15 min +_REQUEST_TIMEOUT = 15.0 + + +class SonarrService: + """Profil de consommation Sonarr : titres téléchargés + genres associés.""" + + def __init__(self) -> None: + self._cache: tuple[float, tuple[set[str], dict[str, int]]] | None = None + + async def _config(self) -> tuple[str, str]: + """(url, apikey) — lu en DB à chaque usage (lecture SQLite négligeable).""" + config = await get_sonarr_config() + return config["url"], config["apikey"] + + def invalidate(self) -> None: + """Force le recalcul du profil au prochain appel (réglages modifiés).""" + self._cache = None + + async def _client(self) -> httpx.AsyncClient | None: + url, apikey = await self._config() + if not url or not apikey: + return None + return httpx.AsyncClient( + base_url=url, + timeout=_REQUEST_TIMEOUT, + headers={"X-Api-Key": apikey}, + ) + + async def _get(self, path: str, params: dict | None = None) -> dict | list | None: + client = await self._client() + if client is None: + return None + try: + async with client: + response = await client.get(path, params=params) + response.raise_for_status() + return response.json() + except (httpx.HTTPError, ValueError) as exc: + logger.warning("Sonarr %s KO : %s", path, exc) + return None + + # ------------------------------------------------------------ profil + + async def profile(self) -> tuple[set[str], dict[str, int]]: + """(titres possédés normalisés, comptage de genres) — vide si non configuré.""" + if self._cache is not None and self._cache[0] > time.monotonic(): + return self._cache[1] + + titles = await self.downloaded_titles() + genres_by_title = await self.series_genres() + + owned: set[str] = set() + genre_counts: dict[str, int] = {} + for title in titles: + normalized = normalize_title(title).casefold() + if normalized: + owned.add(normalized) + for genre in genres_by_title.get(normalized, []): + genre_counts[genre] = genre_counts.get(genre, 0) + 1 + + result = (owned, genre_counts) + self._cache = (time.monotonic() + _PROFILE_TTL, result) + return result + + async def downloaded_titles(self) -> list[str]: + """Titres récemment grabés/importés sur Sonarr (les plus récents d'abord).""" + payload = await self._get( + "/api/v3/history", + { + "page": 1, + "pageSize": _HISTORY_PAGE_SIZE, + "eventType": 1, # grab ; les imports (3) suivent le même titre + "sortKey": "date", + "sortDir": "desc", + }, + ) + if not isinstance(payload, dict): + return [] + titles: list[str] = [] + seen: set[str] = set() + for record in payload.get("records", []): + title = record.get("series", {}).get("title") + if not title or title in seen: + continue + seen.add(title) + titles.append(title) + if len(titles) >= _MAX_TITLES: + break + return titles + + async def series_genres(self) -> dict[str, list[str]]: + """Genres par série, clé = titre normalisé (minuscule).""" + payload = await self._get("/api/v3/series") + if not isinstance(payload, list): + return {} + return { + normalize_title(s.get("title", "")).casefold(): [ + g for g in s.get("genres", []) if isinstance(g, str) + ] + for s in payload + if s.get("title") + } + + # ------------------------------------------------------------ admin + + async def test_connection(self) -> dict: + """Diagnostic admin : version Sonarr, séries, grabs récents.""" + url, apikey = await self._config() + if not url or not apikey: + return {"ok": False, "detail": "URL ou clé API manquante"} + payload = await self._get("/api/v3/system/status") + if payload is None: + return {"ok": False, "detail": "Connexion impossible — vérifiez URL et clé"} + series = await self._get("/api/v3/series") + titles = await self.downloaded_titles() + return { + "ok": True, + "detail": ( + f"Sonarr {payload.get('version', '?')} — " + f"{len(series) if isinstance(series, list) else 0} séries, " + f"{len(titles)} saisies récentes" + ), + } + + +sonarr = SonarrService() diff --git a/app/services/torznab.py b/app/services/torznab.py new file mode 100644 index 0000000..a6ec3a9 --- /dev/null +++ b/app/services/torznab.py @@ -0,0 +1,304 @@ +"""Indexeur Torznab/Newznab : expose le catalogue OhmStreaming à Sonarr/Prowlarr. + +OhmStreaming devient une source d'indexeur à part entière : Sonarr (ou Prowlarr, +qui relaiera vers Radarr/Lidarr…) interroge `/torznab/api` comme n'importe quel +indexer Jackett. Chaque « release » correspond à un épisode résolu depuis les +sources de scraping ; le grab (`/torznab/download`) déclenche l'extraction puis +l'ajout dans la file de téléchargements OhmStreaming — le fichier arrive donc +dans la bibliothèque locale, comme un téléchargement manuel. + +Formats : +- `t=caps` → capacités du serveur (catégories TV/Anime, paramètres supportés) +- `t=tvsearch` → recherche par série + saison + épisode (Sonarr) +- `t=search` → recherche libre (Prowlarr, recherche manuelle) +""" + +import asyncio +import logging +import time +from dataclasses import dataclass +from datetime import UTC, datetime +from email.utils import format_datetime +from urllib.parse import urlencode + +from app.scrapers.base import ( + Episode, + ScrapeError, + SourceScraper, + VideoLink, + all_sources, + import_all_scrapers, + resolve_hoster, +) +from app.services.downloads import download_manager +from app.services.settings import is_source_enabled + +logger = logging.getLogger(__name__) + +import_all_scrapers() + +TORZNAB_NS = "http://torznab.com/schemas/2015/feed" +CAT_TV = "5000" +CAT_ANIME = "5070" +_SIZE_ESTIMATE = 400 * 1024 * 1024 # estimation affichée (~400 Mo/épisode) +_MAX_SERIES_PER_SOURCE = 2 # fiches explorées par source lors d'une recherche +_MAX_RELEASES = 100 # borne du flux retourné +_SEARCH_TIMEOUT = 40.0 # scraping lent : garde-fou par source +_EPISODE_CACHE_TTL = 600.0 # listes d'épisodes re-scrapées au bout de 10 min + + +@dataclass +class Release: + """Un épisode vu comme une release par Sonarr/Prowlarr.""" + + series: str + season: int + ep: int + source: str + source_id: str + episode_url: str + + @property + def sonarr_title(self) -> str: + return f"{self.series} S{self.season:02d}E{self.ep:02d} VOSTFR WEB-DL" + + + +def _bencode(value) -> bytes: + if isinstance(value, int): + return f"i{value}e".encode() + if isinstance(value, str): + raw = value.encode() + return f"{len(raw)}:".encode() + raw + if isinstance(value, bytes): + return f"{len(value)}:".encode() + value + if isinstance(value, dict): + return b"d" + b"".join( + _bencode(k) + _bencode(v) for k, v in sorted(value.items()) + ) + b"e" + if isinstance(value, list): + return b"l" + b"".join(_bencode(v) for v in value) + b"e" + raise TypeError(f"type non encodable en bencode : {type(value)!r}") + + +def torrent_stub(announce_url: str, name: str) -> bytes: + """Fichier .torrent minimal (le vrai téléchargement est fait par OhmStreaming).""" + return _bencode( + { + "announce": announce_url, + "created by": "OhmStreaming", + "comment": name, + "info": { + "name": name + ".mp4", + "length": 0, + "piece length": 32768, + "pieces": b"\x00" * 20, + }, + } + ) + + +class TorznabService: + """Recherche multi-sources mappée en releases Torznab + grab → file interne.""" + + def __init__(self) -> None: + self._episodes_cache: dict[tuple[str, str], tuple[float, list[Episode]]] = {} + + # ------------------------------------------------------------ recherche + + async def tvsearch(self, q: str, season: int | None = None, ep: int | None = None) -> list[Release]: + """Recherche type Sonarr : série (+ saison/épisode optionnels).""" + releases: list[Release] = [] + for source in await self._enabled_sources(): + outcomes = await self._search_source(source, q) + for result in outcomes[:_MAX_SERIES_PER_SOURCE]: + try: + episodes = await self._episodes_of(source, result.source_id) + except (ScrapeError, TimeoutError): + continue + releases.extend( + self._releases_for(result.title, source, result.source_id, episodes, season, ep) + ) + if len(releases) >= _MAX_RELEASES: + return releases[:_MAX_RELEASES] + return releases + + async def search(self, q: str) -> list[Release]: + """Recherche libre : tous les épisodes des fiches trouvées.""" + return await self.tvsearch(q) + + async def _search_source(self, source: SourceScraper, q: str): + try: + return await asyncio.wait_for(source.search(q), timeout=_SEARCH_TIMEOUT) + except (ScrapeError, TimeoutError) as exc: + logger.warning("Torznab : recherche %s KO pour %r : %s", source.name, q, exc) + return [] + + async def _enabled_sources(self) -> list[SourceScraper]: + return [s for s in all_sources() if await is_source_enabled(s.name)] + + async def _episodes_of(self, source: SourceScraper, source_id: str) -> list[Episode]: + """Liste d'épisodes d'une fiche, avec cache mémoire TTL.""" + key = (source.name, source_id) + cached = self._episodes_cache.get(key) + if cached is not None and cached[0] > time.monotonic(): + return cached[1] + episodes = await asyncio.wait_for(source.list_episodes(source_id), timeout=_SEARCH_TIMEOUT) + self._episodes_cache[key] = (time.monotonic() + _EPISODE_CACHE_TTL, episodes) + return episodes + + def _releases_for( + self, + series: str, + source: SourceScraper, + source_id: str, + episodes: list[Episode], + season: int | None, + ep: int | None, + ) -> list[Release]: + """Filtre les épisodes selon saison/épisode demandés (entiers uniquement).""" + out = [] + for item in episodes: + if item.number != int(item.number): # OAV 2.5 → ignorée (inparseable Sonarr) + continue + if season is not None and item.season != season: + continue + if ep is not None and int(item.number) != ep: + continue + out.append( + Release( + series=series, + season=item.season, + ep=int(item.number), + source=source.name, + source_id=source_id, + episode_url=item.url, + ) + ) + return out + + # ------------------------------------------------------------ grab + + async def grab(self, source: str, source_id: str, season: int, ep: int, series: str) -> dict: + """Résout l'épisode (embed → vidéo directe) puis l'ajoute à la file interne. + + Retourne le dict du téléchargement (existant si doublon actif). + Lève ScrapeError si introuvable ou qu'aucun hébergeur n'a répondu. + """ + from app.scrapers.base import get_source + + scraper = get_source(source) + episodes = await self._episodes_of(scraper, source_id) + match = next( + ( + e + for e in episodes + if e.season == season and e.number == int(ep) + ), + None, + ) + if match is None: + raise ScrapeError(f"Épisode S{season:02d}E{ep:02d} introuvable sur {source}") + + link = await self._resolve_video(scraper, match.url) + title = f"{series} S{season:02d}E{ep:02d}" + result = await download_manager.enqueue(link.url, match.url, title) + if link.is_hls or link.headers.get("Referer"): + result["note"] = "HLS/proxy : OhmStreaming gère le téléchargement via ffmpeg" + logger.info("Torznab grab %s → download id=%s", title, result.get("id")) + return result + + async def _resolve_video(self, scraper: SourceScraper, episode_url: str) -> VideoLink: + """Chaîne complète : page épisode → embeds → première URL directe valide.""" + embeds = await asyncio.wait_for( + scraper.extract_embed_links(episode_url), timeout=_SEARCH_TIMEOUT + ) + errors: list[str] = [] + for embed in embeds: + extractor = resolve_hoster(embed) + if extractor is None: + errors.append(f"hébergeur non supporté : {embed}") + continue + try: + link = await asyncio.wait_for(extractor.extract(embed), timeout=_SEARCH_TIMEOUT) + if link and link.url: + return link + except (ScrapeError, TimeoutError) as exc: + errors.append(str(exc)) + raise ScrapeError(f"Aucun hébergeur résolu pour {episode_url} ({'; '.join(errors[:3])})") + + # ------------------------------------------------------------ XML + + def download_url(self, base_url: str, apikey: str, release: Release) -> str: + query = urlencode( + { + "apikey": apikey, + "source": release.source, + "sid": release.source_id, + "season": release.season, + "ep": release.ep, + "series": release.series, + } + ) + return f"{base_url}/torznab/download?{query}" + + def caps_xml(self, base_url: str) -> str: + return f""" + + + + + + + + + + + + + +""" + + def results_xml(self, base_url: str, apikey: str, releases: list[Release]) -> str: + from xml.sax.saxutils import escape + + items = [] + for release in releases: + url = escape(self.download_url(base_url, apikey, release)) + pubdate = escape(format_datetime(datetime.now(UTC))) + items.append(f""" + {escape(release.sonarr_title)} + {url} + {url} + {escape(release.episode_url)} + {pubdate} + {CAT_ANIME} + + + + + + """) + body = "\n".join(items) + return f""" + + + OhmStreaming + {escape(base_url)} + Indexeur OhmStreaming — animes VOSTFR scrapés en direct + fr-FR +{body} + +""" + + def error_xml(self, code: int, description: str) -> str: + from xml.sax.saxutils import escape + + return ( + '\n' + f'' + ) + + +torznab = TorznabService() diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..7ca654a --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,654 @@ +/* ============================================================ + Ohm Stream — Design system « plateforme streaming » + Sombre immersif, accent rouge, typographie Outfit + ============================================================ */ + +:root { + --bg: #0a0a0c; + --surface: #141416; + --surface-2: #1c1c20; + --border: #26262b; + --text: #f5f5f7; + --text-dim: #8e8e96; + --accent: #e50914; + --accent-2: #f5c518; + --success: #46d369; + --warning: #f5c518; + --danger: #ff5c5c; + --radius: 10px; + --radius-lg: 16px; + --shadow: 0 12px 32px rgba(0, 0, 0, 0.5); + --font: "Outfit", system-ui, -apple-system, sans-serif; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font); + min-height: 100vh; + line-height: 1.55; +} + +a { color: inherit; text-decoration: none; } +img { display: block; max-width: 100%; } +button { font-family: inherit; } + +/* ------------------------------------------------------------ topbar */ + +.topbar { + position: sticky; + top: 0; + z-index: 50; + display: flex; + align-items: center; + gap: 1.8rem; + padding: 0.85rem 2.2rem; + background: linear-gradient(180deg, rgba(10, 10, 12, 0.96), rgba(10, 10, 12, 0.8)); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); +} + +.logo { + font-weight: 800; + font-size: 1.25rem; + letter-spacing: 0.04em; + color: var(--accent); + white-space: nowrap; +} +.logo span { color: var(--text); font-weight: 400; } + +.topnav { display: flex; gap: 0.25rem; } +.topnav a { + color: var(--text-dim); + font-size: 0.88rem; + font-weight: 500; + padding: 0.45rem 0.85rem; + border-radius: 99px; + transition: color 0.15s, background 0.15s; +} +.topnav a:hover { color: var(--text); } +.topnav a.active { color: var(--text); background: var(--surface-2); } + +.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 0.8rem; } + +.avatar { + width: 34px; height: 34px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 0.85rem; color: #fff; + text-transform: uppercase; +} + +.icon-btn { + background: none; border: none; color: var(--text-dim); + font-size: 1.05rem; cursor: pointer; padding: 0.4rem; + border-radius: 8px; transition: color 0.15s, background 0.15s; +} +.icon-btn:hover { color: var(--text); background: var(--surface-2); } + +/* ------------------------------------------------------------ layout */ + +.main { padding: 2rem clamp(1rem, 3.5vw, 4rem) 4rem; } + +.page-title { font-size: 1.6rem; font-weight: 800; margin-bottom: 0.3rem; letter-spacing: -0.01em; } +.page-sub { color: var(--text-dim); font-size: 0.92rem; margin-bottom: 1.6rem; } + +/* ------------------------------------------------------------ recherche */ + +.search-bar { display: flex; gap: 0.7rem; margin-bottom: 1.8rem; max-width: 720px; } + +.input, .search-bar input { + flex: 1; + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); + padding: 0.85rem 1.3rem; + border-radius: 99px; + font-size: 1rem; + font-family: inherit; + outline: none; + transition: border 0.15s, box-shadow 0.15s; +} +.input:focus, .search-bar input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(229, 9, 20, 0.15); +} + +.btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: var(--accent); + color: #fff; + border: none; + padding: 0.8rem 1.5rem; + border-radius: 99px; + font-size: 0.95rem; + font-weight: 700; + cursor: pointer; + transition: filter 0.15s, transform 0.1s; + white-space: nowrap; +} +.btn:hover { filter: brightness(1.15); } +.btn:active { transform: scale(0.97); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.btn-ghost { + background: rgba(255, 255, 255, 0.1); + border: 1px solid var(--border); + color: var(--text); + backdrop-filter: blur(6px); +} +.btn-ghost:hover { background: rgba(255, 255, 255, 0.16); filter: none; } + +.btn-sm { padding: 0.4rem 0.85rem; font-size: 0.82rem; } +.btn-danger { background: #3d1114; color: var(--danger); border: 1px solid #5c1a1e; } +.btn-danger:hover { background: #4d1518; filter: none; } + +/* ------------------------------------------------------------ grille de posters */ + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(clamp(135px, 12vw, 190px), 1fr)); + gap: 1.1rem; +} + +.card { + position: relative; + border-radius: var(--radius); + overflow: hidden; + background: var(--surface); + transition: transform 0.25s cubic-bezier(0.22, 0.68, 0.35, 1), box-shadow 0.25s; + cursor: pointer; +} +.card:hover { transform: scale(1.04); box-shadow: var(--shadow); z-index: 2; } + +.card-poster { + aspect-ratio: 2 / 3; + width: 100%; + object-fit: cover; + background: var(--surface-2); + transition: transform 0.4s ease; +} +.card:hover .card-poster { transform: scale(1.07); } + +.card-overlay { + position: absolute; + inset: auto 0 0; + padding: 2rem 0.75rem 0.65rem; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.92), transparent); +} + +.card-title { + font-size: 0.84rem; + font-weight: 600; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.card-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 0.35rem; +} + +.card-chip { + position: absolute; + top: 0.5rem; left: 0.5rem; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(4px); + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.18rem 0.5rem; + border-radius: 5px; + color: var(--accent-2); +} + +.badge { + display: inline-block; + padding: 0.15rem 0.55rem; + border-radius: 5px; + font-size: 0.66rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.badge-source { background: rgba(229, 9, 20, 0.18); color: #ff6b73; } +.badge-type { background: rgba(245, 197, 24, 0.14); color: var(--accent-2); } + +/* ------------------------------------------------------------ fiche titre */ + +.title-hero { + position: relative; + border-radius: var(--radius-lg); + overflow: hidden; + margin-bottom: 2rem; + background: var(--surface); + min-height: 380px; + display: flex; + align-items: flex-end; +} +.title-banner { + position: absolute; inset: 0; + background-size: cover; background-position: center 25%; +} +.title-banner::after { + content: ""; + position: absolute; inset: 0; + background: + linear-gradient(90deg, rgba(10, 10, 12, 0.94) 15%, rgba(10, 10, 12, 0.4) 60%, transparent), + linear-gradient(0deg, var(--bg) 0%, transparent 45%); +} +.title-hero-inner { + position: relative; + z-index: 1; + display: flex; + gap: 2rem; + padding: 2.2rem; + width: 100%; + align-items: flex-end; +} +.title-poster { + width: 180px; + border-radius: var(--radius); + box-shadow: var(--shadow); + flex-shrink: 0; +} +.title-info h1 { font-size: 2.4rem; font-weight: 800; letter-spacing: -0.02em; margin-bottom: 0.5rem; } +.title-tags { display: flex; flex-wrap: wrap; gap: 0.45rem; margin: 0.7rem 0; } +.title-synopsis { + color: #cfcfd6; + font-size: 0.95rem; + max-width: 640px; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} +.title-stats { display: flex; gap: 1.3rem; margin-top: 0.8rem; font-size: 0.88rem; color: var(--text-dim); } +.title-stats strong { color: var(--text); } +.title-actions { display: flex; gap: 0.7rem; margin-top: 1.2rem; flex-wrap: wrap; } + +.rating-star { color: var(--accent-2); } + +/* ------------------------------------------------------------ épisodes */ + +.section-title { font-size: 1.15rem; font-weight: 700; margin: 2rem 0 1rem; } + +.episode-list { display: flex; flex-direction: column; gap: 0.55rem; } + +.episode-row { + display: flex; + align-items: center; + gap: 1rem; + background: var(--surface); + border: 1px solid transparent; + border-radius: var(--radius); + padding: 0.65rem 1rem; + transition: background 0.15s, border 0.15s; +} +.episode-row:hover { background: var(--surface-2); border-color: var(--border); } +.episode-num { + min-width: 3rem; + font-weight: 800; + color: var(--accent-2); + font-variant-numeric: tabular-nums; + font-size: 0.95rem; +} +.episode-name { flex: 1; font-size: 0.92rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d6d6dc; } + +/* ------------------------------------------------------------ téléchargements */ + +.progress { + height: 6px; + background: var(--surface-2); + border-radius: 99px; + overflow: hidden; + flex: 1; +} +.progress-fill { + height: 100%; + background: var(--accent); + border-radius: 99px; + transition: width 0.5s ease; +} +.progress-fill.done { background: var(--success); } +.progress-fill.failed, .progress-fill.cancelled { background: var(--danger); } +.progress-fill.paused, .progress-fill.pending { background: var(--warning); } + +.dl-row { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 1.2rem; + display: flex; + flex-direction: column; + gap: 0.6rem; +} +.dl-head { display: flex; align-items: center; gap: 0.9rem; } +.dl-title { flex: 1; font-weight: 600; font-size: 0.95rem; } +.dl-stats { display: flex; gap: 1.2rem; font-size: 0.8rem; color: var(--text-dim); font-variant-numeric: tabular-nums; } +.dl-actions { display: flex; gap: 0.45rem; } + +.status-pill { font-size: 0.7rem; font-weight: 700; padding: 0.2rem 0.65rem; border-radius: 99px; text-transform: uppercase; letter-spacing: 0.03em; } +.status-pending, .status-paused { background: rgba(245, 197, 24, 0.14); color: var(--warning); } +.status-downloading { background: rgba(229, 9, 20, 0.16); color: #ff6b73; } +.status-done { background: rgba(70, 211, 105, 0.14); color: var(--success); } +.status-failed, .status-cancelled { background: rgba(255, 92, 92, 0.14); color: var(--danger); } + +/* ------------------------------------------------------------ lecteur */ + +.player-wrap { + background: #000; + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow); + margin-bottom: 1.2rem; +} +.player-wrap video { width: 100%; max-height: 74vh; } + +/* ------------------------------------------------------------ admin / tables */ + +.table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } +.table th, .table td { padding: 0.7rem 0.9rem; text-align: left; border-bottom: 1px solid var(--border); } +.table th { color: var(--text-dim); font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.06em; } +.table tr:last-child td { border-bottom: none; } + +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.4rem 1.5rem; + margin-bottom: 1.5rem; +} +.panel h2 { font-size: 1.05rem; font-weight: 700; margin-bottom: 1.1rem; } + +.toggle { + position: relative; + width: 44px; height: 24px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 99px; + cursor: pointer; + transition: background 0.2s; + flex-shrink: 0; +} +.toggle::after { + content: ""; + position: absolute; + top: 2px; left: 2px; + width: 18px; height: 18px; + border-radius: 50%; + background: var(--text-dim); + transition: transform 0.2s, background 0.2s; +} +.toggle.on { background: var(--accent); border-color: var(--accent); } +.toggle.on::after { transform: translateX(20px); background: #fff; } + +/* ------------------------------------------------------------ divers */ + +.spinner { + width: 36px; height: 36px; + border: 3px solid var(--surface-2); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 3rem auto; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.empty-state { text-align: center; color: var(--text-dim); padding: 4rem 1rem; font-size: 0.95rem; } +.empty-state .big { font-size: 2.6rem; margin-bottom: 0.7rem; } + +.htmx-indicator { display: none; } +.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: block; } + +.toast { + position: fixed; + bottom: 1.5rem; right: 1.5rem; + background: var(--surface-2); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + color: var(--text); + padding: 0.8rem 1.2rem; + border-radius: var(--radius); + box-shadow: var(--shadow); + font-size: 0.88rem; + z-index: 100; + animation: toast-in 0.25s ease; +} +@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } } + +/* ------------------------------------------------------------ login */ + +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: + radial-gradient(ellipse 55% 45% at 50% 0%, rgba(229, 9, 20, 0.13), transparent), + var(--bg); +} +.login-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 2.5rem; + width: 400px; + box-shadow: var(--shadow); +} +.login-card .logo { display: block; text-align: center; font-size: 1.5rem; margin-bottom: 0.3rem; } +.login-sub { text-align: center; color: var(--text-dim); font-size: 0.88rem; margin-bottom: 1.8rem; } +.login-card form { display: flex; flex-direction: column; gap: 0.85rem; } +.login-card .input { border-radius: var(--radius); } +.login-card .btn { border-radius: var(--radius); justify-content: center; } +.login-tabs { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; background: var(--surface-2); padding: 0.3rem; border-radius: 99px; } +.login-tab { + flex: 1; + text-align: center; + padding: 0.5rem; + border-radius: 99px; + cursor: pointer; + color: var(--text-dim); + font-size: 0.88rem; + font-weight: 600; + transition: all 0.15s; +} +.login-tab.active { background: var(--accent); color: #fff; } +.login-error { + border: 1px solid rgba(255, 92, 92, 0.4); + color: var(--danger); + border-radius: var(--radius); + padding: 0.6rem 0.9rem; + font-size: 0.85rem; +} + +.card-year { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(4px); + font-size: 0.68rem; + font-weight: 700; + padding: 0.18rem 0.45rem; + border-radius: 5px; + color: var(--text-dim); +} + +.card-airing { + position: absolute; + bottom: 3.4rem; + left: 0.5rem; + background: rgba(46, 160, 67, 0.85); + color: #fff; + font-size: 0.58rem; + font-weight: 800; + letter-spacing: 0.06em; + padding: 0.16rem 0.45rem; + border-radius: 5px; +} + +[x-cloak] { display: none !important; } + +/* ---------------------------------------------------------- découverte (rails) */ + +.rail-section { margin-bottom: 2.4rem; animation: rise 0.5s ease backwards; } + +.rail-title { + font-size: 1.15rem; + font-weight: 700; + margin: 0 0 0.8rem; + display: flex; + align-items: baseline; + gap: 0.6rem; + flex-wrap: wrap; +} + +.rail-source { + font-size: 0.8rem; + font-weight: 500; + color: var(--text-dim); +} + +.rail-wrap { position: relative; } + +.rail { + --visible: 2.4; + --rail-gap: 1.1rem; + display: flex; + gap: var(--rail-gap); + overflow-x: auto; + padding: 0.3rem 0.2rem 0.8rem; + scroll-snap-type: x proximity; + scroll-behavior: smooth; +} +.rail::-webkit-scrollbar { height: 8px; } +.rail::-webkit-scrollbar-thumb { background: var(--surface-2); border-radius: 4px; } + +.rail-card { + flex: 1 0 calc((100% - (var(--visible) - 1) * var(--rail-gap)) / var(--visible)); + max-width: 300px; + scroll-snap-align: start; + animation: card-in 0.55s cubic-bezier(0.22, 0.68, 0.35, 1) backwards; + animation-delay: calc(var(--i, 0) * 45ms); +} + +.rail-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 3; + width: 42px; + height: 42px; + border-radius: 50%; + border: 1px solid var(--border); + background: rgba(20, 20, 22, 0.85); + backdrop-filter: blur(6px); + color: var(--text); + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s, background 0.2s, transform 0.2s; + display: flex; + align-items: center; + justify-content: center; +} +.rail-prev { left: -10px; } +.rail-next { right: -10px; } +.rail-wrap:hover .rail-nav { opacity: 1; } +.rail-nav:hover { background: var(--accent); transform: translateY(-50%) scale(1.1); } +@media (hover: none) { .rail-nav { display: none; } } + +/* ------------------------------------------------------------ skeletons */ + +.skel { + background: linear-gradient(100deg, var(--surface) 40%, var(--surface-2) 50%, var(--surface) 60%); + background-size: 200% 100%; + animation: shimmer 1.4s linear infinite; +} +.skel-title { height: 1.2rem; width: 220px; border-radius: 6px; margin-bottom: 0.8rem; } +.skel-card { aspect-ratio: 2 / 3; border-radius: var(--radius); } +.rail-skel { overflow: hidden; } + +/* ------------------------------------------------------------ animations */ + +@keyframes card-in { from { opacity: 0; transform: translateY(16px) scale(0.96); } } +@keyframes rise { from { opacity: 0; transform: translateY(14px); } } +@keyframes shimmer { to { background-position: -200% 0; } } + +.page-title { animation: rise 0.4s ease backwards; } +.page-sub { animation: rise 0.4s 0.08s ease backwards; } +.grid .card { + animation: card-in 0.5s cubic-bezier(0.22, 0.68, 0.35, 1) backwards; + animation-delay: calc(var(--i, 0) * 40ms); +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-delay: 0ms !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* ------------------------------------------------------------ responsive */ + +@media (min-width: 720px) { .rail { --visible: 3.4; } } +@media (min-width: 1000px) { .rail { --visible: 4; } } +@media (min-width: 1200px) { .rail { --visible: 5; } } +@media (min-width: 1450px) { .rail { --visible: 6; } } +@media (min-width: 1700px) { .rail { --visible: 7; } } +@media (min-width: 1920px) { .rail { --visible: 8; } } +@media (min-width: 2150px) { .rail { --visible: 9; } } +@media (min-width: 2400px) { .rail { --visible: 10; } } +@media (min-width: 2650px) { .rail { --visible: 12; } } +@media (min-width: 3000px) { .rail { --visible: 13; } } +@media (min-width: 3400px) { .rail { --visible: 14; } } +@media (min-width: 3800px) { .rail { --visible: 15; } } + +@media (min-width: 1800px) { + .page-title { font-size: 1.8rem; } + .rail-title { font-size: 1.3rem; } +} + +@media (max-width: 860px) { + .topbar { padding: 0.6rem 1rem 0; gap: 0.8rem; flex-wrap: wrap; } + .topbar-right { margin-left: auto; } + .topnav { + order: 3; + width: 100%; + overflow-x: auto; + scrollbar-width: none; + padding-bottom: 0.5rem; + } + .topnav::-webkit-scrollbar { display: none; } + .topnav a { padding: 0.4rem 0.7rem; font-size: 0.82rem; white-space: nowrap; } + .main { padding: 1.2rem 1rem 3rem; } + .title-hero { min-height: 0; } + .title-hero-inner { flex-direction: column; align-items: flex-start; padding: 1.4rem; } + .title-info h1 { font-size: 1.8rem; } + .title-poster { width: 130px; } +} + +@media (max-width: 640px) { + .main { padding: 1rem 0.9rem 3rem; } + .page-title { font-size: 1.35rem; } + .page-sub { font-size: 0.86rem; margin-bottom: 1.2rem; } + .grid { grid-template-columns: repeat(auto-fill, minmax(105px, 1fr)); gap: 0.7rem; } + .rail { --rail-gap: 0.7rem; } + .rail-section { margin-bottom: 1.8rem; } + .search-bar { flex-direction: column; } + .search-bar .btn { justify-content: center; } +} diff --git a/app/static/img/placeholder.svg b/app/static/img/placeholder.svg new file mode 100644 index 0000000..0277740 --- /dev/null +++ b/app/static/img/placeholder.svg @@ -0,0 +1,5 @@ + + + ⛩ + Pas d'image + diff --git a/app/static/js/app.js b/app/static/js/app.js new file mode 100644 index 0000000..c17b284 --- /dev/null +++ b/app/static/js/app.js @@ -0,0 +1,12 @@ +// Petit toast global utilisé par les pages +function toast(message) { + const el = document.createElement('div'); + el.className = 'toast'; + el.textContent = message; + document.body.appendChild(el); + setTimeout(() => { + el.style.opacity = '0'; + el.style.transition = 'opacity 0.4s'; + setTimeout(() => el.remove(), 400); + }, 2800); +} diff --git a/app/templates/admin.html b/app/templates/admin.html new file mode 100644 index 0000000..1752d25 --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,199 @@ +{% extends "base.html" %} +{% set active = 'admin' %} +{% block title %}Administration — Ohm Stream{% endblock %} + +{% block content %} +

Administration

+

Utilisateurs, sources et santé du système.

+ +
+ +
+

📊 Statistiques

+
+ Utilisateurs : + +
+
+ +
+

🌐 Sources de scraping

+ + + + + +
SourceURLActivéeSanté
+
+ +
+

🔗 Intégrations Sonarr / Prowlarr

+ +

OhmStreaming comme indexeur (Torznab)

+

+ Ajoutez un indexeur « Torznab » dans Prowlarr ou Sonarr avec cette URL et cette clé — + les épisodes grabés par Sonarr entrent directement dans la file de téléchargements OhmStreaming. +

+ + + + + + + + + + + + + +
URL
Clé API + + +
+ +

Sonarr → « Pour toi »

+

+ Les genres des séries téléchargées sur Sonarr personnalisent la section + « Pour toi » (ce qui y est possédé n'est pas re-recommandé). +

+
+ + + + +
+

+
+ +
+

👥 Utilisateurs

+ + + + + +
IDNomRôleActifCréé le
+
+ + +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..26dfe09 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,37 @@ + + + + + + {% block title %}Ohm Stream{% endblock %} + + + + + + + +
+ + +
+
+
+ +
+
+
+
+ {% block content %}{% endblock %} +
+ +{% block scripts %}{% endblock %} + + diff --git a/app/templates/discover.html b/app/templates/discover.html new file mode 100644 index 0000000..c388b1b --- /dev/null +++ b/app/templates/discover.html @@ -0,0 +1,141 @@ +{% extends "base.html" %} +{% set active = 'discover' %} +{% block title %}Découvrir — Ohm Stream{% endblock %} + +{% block content %} +

Découvrir

+

Nouveautés de tes sources, incontournables et suggestions basées sur tes téléchargements.

+ +
+ + +
+
+
+
+ +
+
+
+
+
+ +
+
+
+ + + + +
+

🆕 Nouveautés de tes sources, triées par date de sortie

+
+ +
+ +
+ +
+
+ + +
+

🔥 Incontournables les classiques les mieux notés

+
+ +
+ +
+ +
+
+ + +
+

✨ Pour toi + +

+
+ +
+ +
+ +
+
+ + +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/downloads.html b/app/templates/downloads.html new file mode 100644 index 0000000..2d032ee --- /dev/null +++ b/app/templates/downloads.html @@ -0,0 +1,122 @@ +{% extends "base.html" %} +{% set active = 'downloads' %} +{% block title %}Téléchargements — Ohm Stream{% endblock %} + +{% block content %} +
+
+
+

Téléchargements

+

+ tâche(s) — + +

+
+
+ + +
+ + + +
+ +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/favorites.html b/app/templates/favorites.html new file mode 100644 index 0000000..c19069a --- /dev/null +++ b/app/templates/favorites.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% set active = 'favorites' %} +{% block title %}Favoris — Ohm Stream{% endblock %} + +{% block content %} +

Favoris

+

+ +
+
+ + + +
+ +
+ +
+ + +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..2bf8cda --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% set active = 'search' %} +{% block title %}Recherche — Ohm Stream{% endblock %} + +{% block content %} +

Recherche unifiée

+

Une seule requête interroge toutes tes sources activées.

+ +
+ + +
+ + + + + +
+ +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/library.html b/app/templates/library.html new file mode 100644 index 0000000..9de726f --- /dev/null +++ b/app/templates/library.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% set active = 'library' %} +{% block title %}Bibliothèque — Ohm Stream{% endblock %} + +{% block content %} +

Bibliothèque

+

Tes fichiers téléchargés, prêts à être regardés.

+ +
+
+ + + +
+ +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..c1f2825 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,52 @@ + + + + + + Connexion — Ohm Stream + + + + + + + + + + diff --git a/app/templates/title.html b/app/templates/title.html new file mode 100644 index 0000000..666306d --- /dev/null +++ b/app/templates/title.html @@ -0,0 +1,176 @@ +{% extends "base.html" %} +{% set active = 'search' %} +{% block title %}Fiche — Ohm Stream{% endblock %} + +{% block content %} +
+
+ + + + +
+{% endblock %} + + +{% block scripts %} + + +{% endblock %} diff --git a/app/templates/watch.html b/app/templates/watch.html new file mode 100644 index 0000000..0010a83 --- /dev/null +++ b/app/templates/watch.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% set active = 'library' %} +{% block title %}Lecture — Ohm Stream{% endblock %} + +{% block content %} +
+ + +
+ +
+

⏵ Reprise automatique à ta position précédente.

+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9b2e5e2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[project] +name = "ohm-stream" +version = "0.1.0" +description = "Ohm Stream Downloader — centre de contrôle auto-hébergé pour animes et séries VOSTFR" +requires-python = ">=3.13" +dependencies = [ + "aiosqlite>=0.22.1", + "beautifulsoup4>=4.15.0", + "fastapi>=0.141.1", + "httpx>=0.28.1", + "jinja2>=3.1.6", + "lxml>=6.1.3", + "pwdlib[argon2]>=0.3.1", + "pydantic-settings>=2.15.0", + "pyjwt>=2.14.0", + "python-multipart>=0.0.32", + "pyyaml>=6.0.3", + "sse-starlette>=3.4.11", + "uvicorn[standard]>=0.53.0", +] + +[dependency-groups] +dev = [ + "aiohttp>=3.14.3", + "pytest>=9.1.1", + "pytest-asyncio>=1.4.0", + "ruff>=0.16.8", +] + +[tool.ruff] +target-version = "py313" +line-length = 100 +extend-exclude = [".plasma"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +pythonpath = ["."] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e3b1121 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,40 @@ +import os +import tempfile +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest + +# Environnement de test AVANT tout import de l'app +_tmp = Path(tempfile.mkdtemp(prefix="ohm-test-")) +os.environ["OHM_DATA_DIR"] = str(_tmp) +os.environ["OHM_DOWNLOAD_DIR"] = str(_tmp / "downloads") +os.environ["OHM_DATABASE_PATH"] = str(_tmp / "test.db") +os.environ["OHM_SECRET_KEY"] = "test-secret-key-with-32-bytes-minimum!" + +from app.config import get_settings +from app.db import db + + +@pytest.fixture(scope="session") +def anyio_backend() -> str: + return "asyncio" + + +@pytest.fixture(autouse=True) +async def database() -> AsyncIterator[None]: + """DB fraîche par test.""" + get_settings.cache_clear() + await db.connect() + for table in ( + "users", + "refresh_tokens", + "downloads", + "metadata_cache", + "settings", + "favorites", + "watch_progress", + ): + await db.execute(f"DELETE FROM {table}") + yield + await db.close() diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..5e57445 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,123 @@ +"""Tests d'intégration API (auth, downloads, favoris, streaming, admin).""" + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.config import get_settings +from app.db import db +from app.main import app + + +@pytest.fixture +async def client() -> AsyncClient: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest.fixture +async def admin_cookies(client: AsyncClient): + r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"}) + assert r.status_code == 303 + return r.cookies + + +async def test_health(client): + r = await client.get("/health") + assert r.status_code == 200 and r.json() == {"status": "ok"} + + +async def test_pages_require_auth(client): + r = await client.get("/", follow_redirects=False) + assert r.status_code == 303 and r.headers["location"] == "/login" + r = await client.get("/api/downloads", follow_redirects=False) + assert r.status_code == 303 + + +async def test_register_login_me(client, admin_cookies): + r = await client.get("/auth/me", cookies=admin_cookies) + data = r.json() + assert data["username"] == "admin" and data["is_admin"] is True + + +async def test_duplicate_username(client): + await client.post("/auth/register", data={"username": "toto", "password": "secret123"}) + r = await client.post("/auth/register", data={"username": "toto", "password": "secret123"}) + assert r.status_code == 409 + + +async def test_enqueue_bad_internal_url(client, admin_cookies): + r = await client.post( + "/api/downloads", json={"internal_url": "invalide"}, cookies=admin_cookies + ) + assert r.status_code == 422 + + +async def test_favorites_flow(client, admin_cookies): + fav = {"source": "vostfree", "source_id": "1-x", "title": "Frieren"} + r = await client.post("/api/favorites", json=fav, cookies=admin_cookies) + assert r.status_code == 201 + r = await client.post("/api/favorites", json=fav, cookies=admin_cookies) + assert r.status_code == 409 + r = await client.get("/api/favorites", cookies=admin_cookies) + assert r.json()["total"] == 1 + fav_id = r.json()["items"][0]["id"] + r = await client.delete(f"/api/favorites/{fav_id}", cookies=admin_cookies) + assert r.status_code == 200 + + +async def test_stream_range(client, admin_cookies): + payload = b"video-bytes" * 1000 + path = get_settings().download_dir / "ep1.mp4" + path.write_bytes(payload) + await db.execute( + "INSERT INTO downloads (source_key, video_url, page_url, title, file_path, status, " + "total_bytes, downloaded_bytes) VALUES ('k', 'u', 'p', 'Ep 1', 'ep1.mp4', 'done', ?, ?)", + (len(payload), len(payload)), + ) + r = await client.get("/api/stream/1", cookies=admin_cookies) + assert r.status_code == 200 and r.content == payload + r = await client.get("/api/stream/1", headers={"Range": "bytes=10-19"}, cookies=admin_cookies) + assert r.status_code == 206 + assert r.content == payload[10:20] + assert r.headers["content-range"] == f"bytes 10-19/{len(payload)}" + r = await client.get( + "/api/stream/1", headers={"Range": "bytes=999999999-"}, cookies=admin_cookies + ) + assert r.status_code == 416 + + +async def test_admin_required(client): + await client.post("/auth/register", data={"username": "admin2", "password": "secret123"}) + r = await client.post("/auth/register", data={"username": "user", "password": "secret123"}) + user_cookies = r.cookies + r = await client.get("/api/admin/users", cookies=user_cookies) + assert r.status_code == 403 + + +async def test_admin_user_management(client, admin_cookies): + await client.post("/auth/register", data={"username": "user", "password": "secret123"}) + r = await client.get("/api/admin/users", cookies=admin_cookies) + users = {u["username"]: u["id"] for u in r.json()} + assert set(users) == {"admin", "user"} + admin_id, user_id = users["admin"], users["user"] + r = await client.post(f"/api/admin/users/{user_id}/toggle-active", cookies=admin_cookies) + assert r.json() == {"is_active": False} + r = await client.post(f"/api/admin/users/{user_id}/toggle-admin", cookies=admin_cookies) + assert r.json() == {"is_admin": True} + # pas d'auto-modification + r = await client.post(f"/api/admin/users/{admin_id}/toggle-admin", cookies=admin_cookies) + assert r.status_code == 400 + r = await client.delete(f"/api/admin/users/{user_id}", cookies=admin_cookies) + assert r.status_code == 200 + + +async def test_source_toggle(client, admin_cookies): + r = await client.post( + "/api/admin/sources/vostfree/toggle", json={"enabled": False}, cookies=admin_cookies + ) + assert r.json()["enabled"] is False + r = await client.get("/api/sources", cookies=admin_cookies) + states = {s["name"]: s["enabled"] for s in r.json()} + assert states["vostfree"] is False + assert states["french_manga"] is True diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..6908726 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,109 @@ +"""Tests unitaires : format interne, sanitisation, auth, registres scrapers.""" + +import pytest + +from app import auth +from app.scrapers.base import ( + decode_internal_url, + encode_internal_url, + get_source, + import_all_scrapers, + resolve_hoster, +) +from app.services.downloads import sanitize_filename + +# ------------------------------------------------------------ format interne + + +def test_internal_url_roundtrip(): + value = encode_internal_url("https://cdn/x.mp4", "https://site/ep1", "Mon Titre") + assert decode_internal_url(value) == ("https://cdn/x.mp4", "https://site/ep1", "Mon Titre") + + +def test_internal_url_rejects_separator(): + with pytest.raises(ValueError): + encode_internal_url("https://a|b", "p", "t") + + +def test_internal_url_decode_invalid(): + with pytest.raises(ValueError): + decode_internal_url("un|deux") + + +# ------------------------------------------------------------ sanitisation + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("Naruto: Ép 01 / test", "Naruto Ép 01 VOSTFR test"), + ("..", "video"), + ("", "video"), + ("titre/norm\\al", "titre norm al"), + (" espaces multiples ", "espaces multiples"), + ], +) +def test_sanitize_filename(raw, expected): + assert sanitize_filename(raw) == expected + + +def test_sanitize_no_traversal(): + assert "/" not in sanitize_filename("../../etc/passwd") + assert sanitize_filename("../../etc/passwd") == "etc passwd" + + +# ------------------------------------------------------------ auth + + +async def test_first_user_is_admin(): + user = await auth.create_user("alice", "password1") + assert user.is_admin is True + second = await auth.create_user("bob", "password1") + assert second.is_admin is False + + +async def test_authenticate(): + await auth.create_user("carol", "password1") + assert await auth.authenticate("carol", "password1") is not None + assert await auth.authenticate("carol", "wrong") is None + assert await auth.authenticate("nobody", "password1") is None + + +async def test_access_token_roundtrip(): + user = await auth.create_user("dave", "password1") + token = auth.create_access_token(user) + payload = auth.decode_access_token(token) + assert payload["username"] == "dave" + assert auth.decode_access_token("garbage") is None + + +async def test_refresh_token_rotation(): + user = await auth.create_user("erin", "password1") + token = await auth.create_refresh_token(user.id) + assert (await auth.use_refresh_token(token)).username == "erin" + # Un refresh token est à usage unique (rotation) + assert await auth.use_refresh_token(token) is None + + +async def test_disabled_account_cannot_login(): + user = await auth.create_user("frank", "password1") + await auth.db.execute("UPDATE users SET is_active = 0 WHERE id = ?", (user.id,)) + assert await auth.authenticate("frank", "password1") is None + + +# ------------------------------------------------------------ registres scrapers + + +def test_sources_registered(): + import_all_scrapers() + assert get_source("vostfree").name == "vostfree" + assert get_source("french_manga").name == "french_manga" + + +def test_hoster_resolution(): + import_all_scrapers() + assert resolve_hoster("https://video.sibnet.ru/shell.php?videoid=1").name == "sibnet" + assert resolve_hoster("https://uqload.com/embed-x.html").name == "uqload" + assert resolve_hoster("https://vidmoly.to/embed-x.html").name == "vidmoly" + assert resolve_hoster("https://sendvid.com/embed/x").name == "sendvid" + assert resolve_hoster("https://inconnu.example.com/v/1") is None diff --git a/tests/test_discover.py b/tests/test_discover.py new file mode 100644 index 0000000..24d7e0e --- /dev/null +++ b/tests/test_discover.py @@ -0,0 +1,367 @@ +"""Tests découverte : parsing latest(), service for_you et endpoint /api/discover.""" + +import asyncio + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.db import db +from app.main import app +from app.scrapers.base import ScrapeError, SearchResult +from app.services.discover import DiscoverService, category_slug, discover + +# --------------------------------------------------------------- parsing latest + +VOSTFREE_LATEST_HTML = """ + +
+ +
VOSTFR
+ Helck VOSTFR +
+
+
+
+
+
+
+ +""" + +FRENCH_MANGA_LATEST_HTML = """ + +
+ + affiche + + 11 / 12 +
Draw This, Then Die! - Saison 1 (2024)
+
+
+ +
+ +""" + + +class _FakeSource: + """Source factice injectée dans le registre du service discover.""" + + name = "fake" + label = "Fake" + + def __init__(self, results: list[SearchResult] | None = None, error: bool = False): + self._results = results or [] + self._error = error + + async def latest(self) -> list[SearchResult]: + if self._error: + raise ScrapeError("source indisponible") + return self._results + + +@pytest.fixture +async def client() -> AsyncClient: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest.fixture +async def admin_cookies(client: AsyncClient): + r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"}) + assert r.status_code == 303 + return r.cookies + + +def test_category_slug(): + assert category_slug("Action") == "action" + assert category_slug("Slice of Life") == "slice-of-life" + assert category_slug("Comédie") == "comedie" # accents retirés + assert category_slug(" Super Power! ") == "super-power" + + +async def test_latest_vostfree_parsing(monkeypatch): + + async def fake_fetch_soup(url, **kwargs): + from bs4 import BeautifulSoup + + return BeautifulSoup(VOSTFREE_LATEST_HTML, "lxml") + + monkeypatch.setattr("app.scrapers.sources.vostfree.fetch_soup", fake_fetch_soup) + results = await asyncio.shield(_latest_of("vostfree")) + assert len(results) == 1 # les blocs sans lien/slug valide sont ignorés + item = results[0] + assert item.source == "vostfree" + assert item.source_id == "1404-helck-vostfr-ddl-streaming-1fichier-uptobox.html".removesuffix( + ".html" + ) + assert item.title == "Helck VOSTFR" # depuis l'attribut alt + assert item.image_url == "https://vostfree.ws/uploads/posts/helck.jpg" + + +async def _latest_of(name: str) -> list[SearchResult]: + from app.scrapers.base import get_source + + return await get_source(name).latest() + + +async def test_latest_french_manga_parsing(monkeypatch): + async def fake_fetch_soup(url, **kwargs): + from bs4 import BeautifulSoup + + return BeautifulSoup(FRENCH_MANGA_LATEST_HTML, "lxml") + + monkeypatch.setattr("app.scrapers.sources.french_manga.fetch_soup", fake_fetch_soup) + results = await _latest_of("french_manga") + assert len(results) == 1 + item = results[0] + assert item.source == "french_manga" + assert item.source_id == "1498905" + assert item.title == "Draw This, Then Die! - Saison 1" # année en queue retirée + assert item.image_url == "https://image.tmdb.org/t/p/w500/dZp.jpg" + assert item.url.endswith("newsid=1498905") + + +async def test_latest_merges_and_sorts_by_release_date(monkeypatch): + """Sources fusionnées, doublons retirés, tri du plus récent au plus ancien.""" + import app.services.discover as discover_module + + src_a = _FakeSource( + [ + SearchResult(source="a", source_id="1", title="Frieren VOSTFR", url="https://x/1"), + SearchResult(source="a", source_id="2", title="Helck VOSTFR", url="https://x/2"), + ] + ) + src_b = _FakeSource( + [ + SearchResult(source="b", source_id="9", title="Frieren - Saison 1", url="https://y/9"), + SearchResult(source="b", source_id="8", title="Old Anime", url="https://y/8"), + ] + ) + monkeypatch.setattr(discover_module, "all_sources", lambda: [src_a, src_b]) + + async def enabled(name: str) -> bool: + return True + + monkeypatch.setattr(discover_module, "is_source_enabled", enabled) + + async def fake_match(title: str): + return None # aucun match → items sans date, triés en fin de liste + + service = DiscoverService() + monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match) + result = await service.latest(limit=10) + + titles = [item["title"] for item in result] + assert titles.count("Frieren VOSTFR") + titles.count("Frieren - Saison 1") == 1 # dédoublonné + assert "Helck VOSTFR" in titles and "Old Anime" in titles + assert all(item["start_date"] is None for item in result) + + +async def test_latest_orders_by_kitsu_start_date(monkeypatch): + """Les dates de sortie Kitsu pilotent l'ordre (plus récent d'abord).""" + import app.services.discover as discover_module + + src = _FakeSource( + [ + SearchResult(source="a", source_id=str(i), title=f"Anime {i}", url=f"https://x/{i}") + for i in range(3) + ] + ) + monkeypatch.setattr(discover_module, "all_sources", lambda: [src]) + + async def enabled(name: str) -> bool: + return True + + monkeypatch.setattr(discover_module, "is_source_enabled", enabled) + + async def fake_match(title: str): + dates = {"Anime 0": "2021-01-01", "Anime 1": "2026-01-01", "Anime 2": None} + start = dates[title] + return {"id": "1", "attributes": {"startDate": start}, "genres": []} + + service = DiscoverService() + monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match) + result = await service.latest(limit=10) + assert [item["title"] for item in result] == ["Anime 1", "Anime 0", "Anime 2"] + assert result[0]["rating"] is None + + +async def test_latest_skips_broken_source_and_uses_cache(monkeypatch): + """Une source en échec disparaît sans erreur, et le TTL évite les re-scrapes.""" + import app.services.discover as discover_module + + calls = {"n": 0} + + def make_source(): + async def latest(): + calls["n"] += 1 + return [] + + return type("S", (), {"name": "s", "label": "S", "latest": staticmethod(latest)})() + + monkeypatch.setattr( + discover_module, "all_sources", lambda: [_FakeSource(error=True), make_source()] + ) + + async def enabled(name: str) -> bool: + return True + + monkeypatch.setattr(discover_module, "is_source_enabled", enabled) + + service = DiscoverService() + assert await service.latest() == [] + assert await service.latest() == [] + assert calls["n"] == 1 # deuxième appel servi depuis le cache TTL + + +# --------------------------------------------------------------- pour toi + + +@pytest.fixture +async def history(monkeypatch): + """Téléchargements + favoris en base, Kitsu mocké.""" + cursor = await db.execute( + "INSERT INTO users (username, password_hash) VALUES (?, ?)", + ("tester", "x" * 64), + ) + user_id = cursor.lastrowid + await db.execute( + "INSERT INTO downloads (source_key, video_url, title, status) VALUES (?,?,?,?)", + ("k1", "https://v/1", "Frieren S1 - E1", "done"), + ) + await db.execute( + "INSERT INTO downloads (source_key, video_url, title, status) VALUES (?,?,?,?)", + ("k2", "https://v/2", "Helck VOSTFR", "done"), + ) + await db.execute( + "INSERT INTO favorites (user_id, source, source_id, title, image_url, payload) " + "VALUES (?,?,?,?,?,?)", + (user_id, "vostfree", "x", "Favori", None, '{"genres": ["Comedy", "Action"]}'), + ) + + async def fake_search_anime(title: str): + lowered = title.lower() + if "frieren" in lowered: + return {"id": "46474", "attributes": {}, "genres": []} # genres → via catégories + if "helck" in lowered: + return {"id": "999", "attributes": {}, "genres": ["Action"]} + return None + + async def fake_categories(anime_id: object) -> list[str]: + return ["Fantasy", "Adventure"] if anime_id == "46474" else [] + + async def fake_kitsu_anime(params: dict) -> list[dict]: + slugs = frozenset(params["filter[categories]"].split(",")) + assert params["sort"] == "-userCount" + catalog = { + frozenset(["fantasy", "adventure", "action", "comedy"]): [ + {"title": "Helck", "kitsu_id": "999"}, + {"title": "Sousou no Frieren", "kitsu_id": "46474"}, + {"title": "Konosuba", "kitsu_id": "1"}, + ], + } + return catalog.get(slugs, []) + + service = DiscoverService() + service.test_user_id = user_id # pour les assertions du test + monkeypatch.setattr(service._kitsu, "search_anime", fake_search_anime) + monkeypatch.setattr(service, "_kitsu_categories", fake_categories) + monkeypatch.setattr(service, "_kitsu_anime", fake_kitsu_anime) + return service + + +async def test_for_you_aggregates_genres_and_excludes_owned(history): + result = await history.for_you(user_id=history.test_user_id, limit=10) + assert set(result["based_on"]) == {"Action", "Fantasy", "Adventure", "Comedy"} + assert result["based_on"][0] == "Action" # 2 occurrences (téléchargement + favori) + titles = [item["title"] for item in result["items"]] + assert "Helck" not in titles # déjà possédé (« Helck VOSTFR » → « helck ») → exclu + assert "Sousou no Frieren" in titles + assert "Konosuba" in titles + + +async def test_for_you_empty_without_history(monkeypatch): + service = DiscoverService() + result = await service.for_you(user_id=42) + assert result == {"based_on": [], "items": []} + + + +class _FakeSonarr: + """Profil Sonarr factice : titres possédés + genres.""" + + def __init__(self, owned: set[str], genres: dict[str, int]) -> None: + self._profile = (owned, genres) + + async def profile(self) -> tuple[set[str], dict[str, int]]: + return self._profile + + +async def test_for_you_merges_sonarr_genres_and_owned(monkeypatch, history): + monkeypatch.setattr( + "app.services.discover.sonarr", + _FakeSonarr( + owned={"mob psycho 100"}, # déjà possédé sur Sonarr → exclu + genres={"Fantasy": 5, "Action": 5}, # Action cumule avec l'historique local + ), + ) + async def fake_kitsu_anime(params: dict) -> list[dict]: + return [ + {"title": "Konosuba", "kitsu_id": "1"}, + {"title": "Mob Psycho 100", "kitsu_id": "2"}, + ] + + monkeypatch.setattr(history, "_kitsu_anime", fake_kitsu_anime) + result = await history.for_you(user_id=history.test_user_id, limit=10) + # genres locaux (Action×2, Fantasy, Adventure, Comedy) + Sonarr (Fantasy+5, Action+5) + counts = {"Action": 7, "Fantasy": 6, "Adventure": 1, "Comedy": 1} + assert sorted(result["based_on"]) == sorted( + sorted(counts, key=counts.get, reverse=True)[:4] + ) + titles = [item["title"] for item in result["items"]] + assert "Mob Psycho 100" not in titles # possédé sur Sonarr → exclu + assert "Konosuba" in titles + +# --------------------------------------------------------------- endpoint + + +async def test_api_discover_requires_auth(client): + r = await client.get("/api/discover", follow_redirects=False) + assert r.status_code == 303 + + +async def test_api_discover_sections(client, admin_cookies, monkeypatch): + async def fake_latest(limit: int = 24): + return [ + {"source": "vostfree", "label": "Vostfree", "source_id": "a", "title": "T", + "start_date": "2026-01-01", "status": "current", "rating": 8.1}, + ] + + async def fake_must_watch(limit: int = 20): + return [{"kitsu_id": "1", "title": "Attack on Titan", "rating": 8.5}] + + async def fake_for_you(user_id: int, limit: int = 20): + return {"based_on": ["Action"], "items": [{"kitsu_id": "2", "title": "X"}]} + + monkeypatch.setattr(discover, "latest", fake_latest) + monkeypatch.setattr(discover, "must_watch", fake_must_watch) + monkeypatch.setattr(discover, "for_you", fake_for_you) + + r = await client.get("/api/discover", cookies=admin_cookies) + assert r.status_code == 200 + data = r.json() + assert data["latest"][0]["label"] == "Vostfree" + assert data["latest"][0]["start_date"] == "2026-01-01" + assert data["must_watch"][0]["title"] == "Attack on Titan" + assert data["for_you"]["based_on"] == ["Action"] + + +async def test_discover_page_renders(client, admin_cookies): + r = await client.get("/discover", cookies=admin_cookies) + assert r.status_code == 200 + assert "Découvrir" in r.text diff --git a/tests/test_downloads.py b/tests/test_downloads.py new file mode 100644 index 0000000..485298c --- /dev/null +++ b/tests/test_downloads.py @@ -0,0 +1,104 @@ +"""Tests du gestionnaire de téléchargements (serveur HTTP local avec Range).""" + +import asyncio + +import pytest +from aiohttp import web + +from app.config import get_settings +from app.services.downloads import download_manager as dm + +PAYLOAD = b"x" * 500_000 + + +async def _start_file_server() -> tuple[web.AppRunner, int]: + """Mini serveur HTTP supportant les requêtes Range.""" + + async def handle(request: web.Request) -> web.StreamResponse: + range_header = request.headers.get("Range") + if range_header: + start = int(range_header.removeprefix("bytes=").split("-")[0]) + return web.Response( + body=PAYLOAD[start:], + status=206, + headers={"Content-Range": f"bytes {start}-{len(PAYLOAD) - 1}/{len(PAYLOAD)}"}, + ) + return web.Response(body=PAYLOAD, headers={"Content-Length": str(len(PAYLOAD))}) + + app = web.Application() + app.router.add_get("/video.mp4", handle) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] + return runner, port + + +@pytest.fixture +async def file_server(): + runner, port = await _start_file_server() + yield f"http://127.0.0.1:{port}" + await runner.cleanup() + + +@pytest.fixture +async def manager(): + await dm.start() + yield dm + await dm.stop() + + +async def _wait_status(download_id: int, wanted: set[str], timeout: float = 10) -> dict: + for _ in range(int(timeout * 10)): + data = await dm.get(download_id) + if data["status"] in wanted: + return data + await asyncio.sleep(0.1) + raise AssertionError(f"statut {wanted} non atteint (actuel: {data['status']})") + + +async def test_full_download(file_server, manager): + d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Episode Test 1") + data = await _wait_status(d["id"], {"done", "failed"}) + assert data["status"] == "done", data.get("error") + assert data["percent"] == 100.0 + path = get_settings().download_dir / data["file_path"] + assert path.read_bytes() == PAYLOAD + + +async def test_anti_duplicate(file_server, manager): + d1 = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Ep") + d2 = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Ep") + assert d1["id"] == d2["id"] + assert d2["duplicate"] is True + await _wait_status(d1["id"], {"done", "failed"}) + + +async def test_pause_resume(file_server, manager): + d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Ep Pause") + await asyncio.sleep(0.2) + await dm.pause(d["id"]) + data = await _wait_status(d["id"], {"paused", "done"}) + if data["status"] == "paused": # assez rapide pour être pausé + assert await dm.resume(d["id"]) is True + data = await _wait_status(d["id"], {"done", "failed"}) + assert data["status"] == "done", data.get("error") + path = get_settings().download_dir / data["file_path"] + assert path.read_bytes() == PAYLOAD + + +async def test_failed_download_and_retry(manager): + d = await dm.enqueue("http://127.0.0.1:1/nope.mp4", "http://page", "Ep KO") + data = await _wait_status(d["id"], {"failed"}) + assert data["error"] + assert await dm.retry(d["id"]) is True + data = await _wait_status(d["id"], {"failed"}) + assert await dm.cancel(d["id"]) is True + + +async def test_cancel(file_server, manager): + d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Ep Cancel") + await dm.cancel(d["id"]) + data = await dm.get(d["id"]) + assert data["status"] in ("cancelled", "done") # course possible si déjà fini diff --git a/tests/test_hls.py b/tests/test_hls.py new file mode 100644 index 0000000..8972c84 --- /dev/null +++ b/tests/test_hls.py @@ -0,0 +1,52 @@ +"""Tests : réécriture de playlists HLS (proxy) et sélection de variante.""" + +from app.routers.proxy import _rewrite_playlist +from app.services.downloads import _best_variant, _parse_ffmpeg_time + +MASTER = """#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=400000,RESOLUTION=640x360 +low/index.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=900000,RESOLUTION=1280x720 +https://cdn.example.com/high/index.m3u8 +""" + +MEDIA = """#EXTM3U +#EXT-X-KEY:METHOD=AES-128,URI="https://cdn.example.com/key.bin",IV=0xabc +#EXTINF:6.0, +seg-1.ts +#EXTINF:6.0, +https://cdn.example.com/seg-2.ts +""" + + +def test_best_variant_picks_highest_bandwidth(): + url = _best_variant(MASTER, "https://site.example.com/master.m3u8") + assert url == "https://cdn.example.com/high/index.m3u8" + + +def test_best_variant_none_for_media_playlist(): + assert _best_variant(MEDIA, "https://x/") is None + + +def test_parse_ffmpeg_time(): + assert _parse_ffmpeg_time("00:01:30.50") == 90.5 + assert _parse_ffmpeg_time("01:00:00.00") == 3600.0 + + +def test_rewrite_playlist_routes_everything_through_proxy(): + out = _rewrite_playlist(MEDIA, "https://cdn.example.com/hls/master.m3u8", "https://ref/") + assert "/api/proxy?url=" in out + # URI relative des segments résolue contre l'URL de base + assert "seg-1.ts" in out and "https%3A%2F%2Fcdn.example.com%2Fhls%2Fseg-1.ts" in out + # URI absolue conservée mais proxiée + assert "seg-2.ts" in out + # La clé AES dans l'attribut URI="..." est aussi réécrite + assert 'URI="/api/proxy?url=' in out + # Le referer est propagé aux URLs proxiées + assert "ref=https%3A%2F%2Fref%2F" in out + + +def test_rewrite_playlist_without_referer(): + out = _rewrite_playlist("#EXTM3U\nseg.ts\n", "https://cdn.example.com/m.m3u8", None) + assert "ref=" not in out + assert out.startswith("#EXTM3U") diff --git a/tests/test_torznab.py b/tests/test_torznab.py new file mode 100644 index 0000000..a39cb9d --- /dev/null +++ b/tests/test_torznab.py @@ -0,0 +1,237 @@ +"""Tests de l'indexeur Torznab (compatibilité Sonarr / Prowlarr).""" + +import xml.etree.ElementTree as ET + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.db import db +from app.main import app +from app.scrapers.base import Episode, SearchResult, VideoLink +from app.services.settings import get_torznab_apikey +from app.services.torznab import torznab as service + + +@pytest.fixture +async def client() -> AsyncClient: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest.fixture +async def apikey() -> str: + return await get_torznab_apikey() + + +class FakeSource: + name = "fake" + label = "Fake" + base_url = "https://fake.example" + + async def search(self, query: str) -> list[SearchResult]: + return [ + SearchResult( + source="fake", + source_id="frieren-1", + title="Frieren", + url="https://fake.example/frieren", + ) + ] + + async def list_episodes(self, source_id: str) -> list[Episode]: + return [ + Episode(number=1, title="Épisode 1", url="https://fake.example/ep1", season=1), + Episode(number=2, title="Épisode 2", url="https://fake.example/ep2", season=1), + Episode(number=2.5, title="OAV", url="https://fake.example/oav", season=1), + Episode(number=1, title="Épisode 1", url="https://fake.example/s2ep1", season=2), + ] + + async def extract_embed_links(self, episode_url: str) -> list[str]: + return ["https://embed.example/player"] + + async def get_details(self, source_id): ... + async def latest(self): return [] + + +@pytest.fixture +def fake_source(monkeypatch): + source = FakeSource() + + async def _sources(): + return [source] + + monkeypatch.setattr(service, "_enabled_sources", _sources) + return source + + +# ---------------------------------------------------------------- auth + caps + + +async def test_torznab_rejects_missing_apikey(client): + r = await client.get("/torznab/api", params={"t": "caps"}) + assert r.status_code == 401 + assert 'code="100"' in r.text + + +async def test_torznab_rejects_wrong_apikey(client): + r = await client.get("/torznab/api", params={"t": "caps", "apikey": "mauvaise"}) + assert r.status_code == 401 + + +async def test_torznab_caps(client, apikey): + r = await client.get("/torznab/api", params={"t": "caps", "apikey": apikey}) + assert r.status_code == 200 + root = ET.fromstring(r.text) + assert root.tag == "caps" + tv = root.find(".//tv-search") + assert tv is not None and tv.get("available") == "yes" + assert "q,season,ep" in (tv.get("supportedParams") or "") + assert any(c.get("id") == "5070" for c in root.findall(".//subcat")) + + +async def test_torznab_apikey_via_header(client, apikey): + r = await client.get("/torznab/api", params={"t": "caps"}, headers={"X-Api-Key": apikey}) + assert r.status_code == 200 + + +async def test_torznab_unknown_function(client, apikey): + r = await client.get("/torznab/api", params={"t": "music", "apikey": apikey}) + assert r.status_code == 400 + assert 'code="203"' in r.text + + +# ---------------------------------------------------------------- recherche + + +async def test_torznab_tvsearch_filters_season_ep(client, apikey, fake_source): + r = await client.get( + "/torznab/api", + params={"t": "tvsearch", "q": "Frieren", "season": 1, "ep": 2, "apikey": apikey}, + ) + assert r.status_code == 200 + root = ET.fromstring(r.text) + items = root.findall(".//item") + assert len(items) == 1 + assert items[0].find("title").text == "Frieren S01E02 VOSTFR WEB-DL" + link = items[0].find("link").text + assert "/torznab/download" in link and "season=1" in link and "ep=2" in link + + +async def test_torznab_tvsearch_all_episodes(client, apikey, fake_source): + r = await client.get( + "/torznab/api", params={"t": "tvsearch", "q": "Frieren", "apikey": apikey} + ) + root = ET.fromstring(r.text) + titles = [i.find("title").text for i in root.findall(".//item")] + assert len(titles) == 3 # OAV 2.5 exclue : S01E01, S01E02, S02E01 + assert "Frieren S02E01 VOSTFR WEB-DL" in titles + + +async def test_torznab_search_requires_query(client, apikey): + r = await client.get("/torznab/api", params={"t": "search", "apikey": apikey}) + assert r.status_code == 400 + + +# ---------------------------------------------------------------- grab + + +class _FakeManager: + def __init__(self) -> None: + self.calls: list[tuple[str, str, str]] = [] + + async def enqueue(self, video_url: str, page_url: str, title: str) -> dict: + self.calls.append((video_url, page_url, title)) + return {"id": 7, "title": title, "status": "pending"} + + +class _FakeExtractor: + name = "fakehost" + + async def extract(self, embed_url: str) -> VideoLink: + return VideoLink(url="https://cdn.example/video.mp4", hoster="fakehost") + + +async def test_torznab_download_enqueues_and_returns_torrent(client, apikey, fake_source, monkeypatch): + manager = _FakeManager() + monkeypatch.setattr("app.scrapers.base.get_source", lambda name: fake_source) + monkeypatch.setattr("app.services.torznab.resolve_hoster", lambda url: _FakeExtractor()) + monkeypatch.setattr("app.services.torznab.download_manager", manager) + + r = await client.get( + "/torznab/download", + params={ + "apikey": apikey, + "source": "fake", + "sid": "frieren-1", + "season": 1, + "ep": 2, + "series": "Frieren", + }, + ) + assert r.status_code == 200 + assert r.headers["content-type"].startswith("application/x-bittorrent") + assert r.content.startswith(b"d") and b"OhmStreaming" in r.content # bencode valide + assert manager.calls == [ + ("https://cdn.example/video.mp4", "https://fake.example/ep2", "Frieren S01E02") + ] + + +async def test_torznab_download_rejects_bad_key(client): + r = await client.get("/torznab/download", params={"apikey": "mauvaise"}) + assert r.status_code == 401 + + +async def test_torznab_download_unknown_episode(client, apikey, fake_source, monkeypatch): + monkeypatch.setattr("app.scrapers.base.get_source", lambda name: fake_source) + r = await client.get( + "/torznab/download", + params={ + "apikey": apikey, + "source": "fake", + "sid": "frieren-1", + "season": 9, + "ep": 9, + "series": "Frieren", + }, + ) + assert r.status_code == 502 + assert 'code="300"' in r.text + + +# ---------------------------------------------------------------- admin + + +async def _admin(client: AsyncClient): + r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"}) + assert r.status_code == 303 + return r.cookies + + +async def test_admin_integrations(client, apikey): + cookies = await _admin(client) + r = await client.get("/api/admin/integrations", cookies=cookies) + assert r.status_code == 200 + data = r.json() + assert data["torznab"]["apikey"] == apikey + assert data["torznab"]["endpoint"].endswith("/torznab/api") + assert data["sonarr"] == {"url": "", "apikey": ""} + + r = await client.put( + "/api/admin/integrations/sonarr", + json={"url": "http://sonarr:8989/", "apikey": "abc"}, + cookies=cookies, + ) + assert r.status_code == 200 + row = await db.fetchone("SELECT value FROM settings WHERE key = 'sonarr:url'") + assert row["value"] == '"http://sonarr:8989"' # slash final retiré + + +async def test_admin_regenerate_torznab_key(client, apikey): + cookies = await _admin(client) + r = await client.post("/api/admin/integrations/torznab/regenerate", cookies=cookies) + assert r.status_code == 200 + new_key = r.json()["apikey"] + assert new_key != apikey and len(new_key) == 32 + r = await client.get("/torznab/api", params={"t": "caps", "apikey": apikey}) + assert r.status_code == 401 # l'ancienne clé est révoquée diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..03ef319 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1555 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" }, + { url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" }, + { url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5f/f652055e18d2627e2eed94c7f31a792127cfe38df786635395d742321674/argon2_cffi_bindings-26.1.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:af11ac37a7c53dc16cb7950a6190851b0870fe218b6c60c0bb7ac355234e3083", size = 15434, upload-time = "2026-08-20T07:32:53.143Z" }, + { url = "https://files.pythonhosted.org/packages/76/38/de696045960f5b846d428c0fb6c130ed3da87aac2af209b05c193815404c/argon2_cffi_bindings-26.1.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:db0fcd827ca61622a01b220aadfbece01939acf53888f2cb98cd93e9b1e2c97e", size = 15449, upload-time = "2026-08-20T07:32:54.075Z" }, + { url = "https://files.pythonhosted.org/packages/91/0a/c25af768f6b75a5a71e31207f87c540656b2808c015260444a22763221ad/argon2_cffi_bindings-26.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:28524438cd3e723f25412f63d4fd516ff5bae9ae5aa56acbe2a1404398a0cf31", size = 25683, upload-time = "2026-08-20T07:32:55.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7e/be212c751ab0bcea7f646615f933bf262e8e50b3f7bef32f861d0a2d066b/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac82fc756a446b6ccd7139ce70efa9d8bbe541e7ad579a12dcb52764b7175c5f", size = 27311, upload-time = "2026-08-20T07:32:56.166Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ee/f84b28e4afd13d3cac36c1d8fa8c239d2dc2c51cd978d02ee5d5ad98d9bb/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a4e68eed961a8de6928d1c17ff3dc2a547e0e923c17f8f1cd79fb7bc9502f98", size = 26771, upload-time = "2026-08-20T07:32:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/21/c3/95c07a023691ecd529da9cb6a8f0779e13ebc1bdfaa86d145fdc1c6e7e79/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:151dfaad9de753f4af2a7854e707e4784f2acc434340ade64239c5b104b2d605", size = 27568, upload-time = "2026-08-20T07:32:58.361Z" }, + { url = "https://files.pythonhosted.org/packages/e6/31/3a18e31406d8694b4d6a31573c3e572fff6bed318bb744453eb653766d22/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:061a6919145bbf282ebf1f9c59d3135d4833c25313c8595c0d68cf7712ddfce2", size = 27280, upload-time = "2026-08-20T07:32:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/0b/39/d4be4577e178b2397aa5b5575c8a309bf0da2afe05fe0c72c8f398662d63/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:62ff20cd130c956c7c9144d5fe35228f98b51c579b2439e988b27ef93e16c02a", size = 27776, upload-time = "2026-08-20T07:33:00.325Z" }, + { url = "https://files.pythonhosted.org/packages/71/47/78f4dd96f7411339f723b96fe24039c1bd5835102b8a5ba71ac4ec712ac7/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19423e5d7ac1cc354baab59eaabf18db2ec04ef6593b5abe5a34f323c4a8f87a", size = 26932, upload-time = "2026-08-20T07:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/96bfd37434cc0a848a9066c291d84b28846c4c9ea289ed9866b1164d622b/argon2_cffi_bindings-26.1.0-cp314-cp314t-win32.whl", hash = "sha256:4f84cdd868978d7b7350a566c254042d44216d9e37f241f3a6d3b1dfebeede35", size = 24878, upload-time = "2026-08-20T07:33:02.189Z" }, + { url = "https://files.pythonhosted.org/packages/f1/42/d8b6810abd9b1bd2f47ebbccf460da59c9f32e94888bea4f7b137d998797/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2b741888c93147444fdfc851abd81cc207f37f7f7da42062a00deb3888e57da8", size = 26656, upload-time = "2026-08-20T07:33:03.222Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1", size = 25378, upload-time = "2026-08-20T07:33:04.332Z" }, + { url = "https://files.pythonhosted.org/packages/66/cb/214092c39c4dbcb72cf98b12234ddac2221f8fe2c0acf29c6a70fa83be53/argon2_cffi_bindings-26.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1d98e33bd8bd67d7206c124e200bf2229c4cfa8c9c19f7b44a897f0fc71837eb", size = 25683, upload-time = "2026-08-20T07:33:05.337Z" }, + { url = "https://files.pythonhosted.org/packages/83/e5/02015b83e9b05ccb85ff2ced424cf6e83a12d3810bc7f66d679a92b69ffb/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccaf0a46cbb380f1fd102a874e32aa629fd3cb0c0e94f4943fa1f6d5edc5dac6", size = 27310, upload-time = "2026-08-20T07:33:06.344Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4a/85e612787d0796878b3b4f6bd53dcd5484b6fe7b64cc6fc7b6e6a04cf835/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0c3103fcff20183e593459cfea6e012281c0e76ae3ed8b5565ad1b92eac3990", size = 26771, upload-time = "2026-08-20T07:33:07.429Z" }, + { url = "https://files.pythonhosted.org/packages/f6/84/ccb003b6f9969820e87656398f4d49c857def71a85ca1588a0e809afd7ce/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c49e853a3bef9dd10329f31f702e7fa9b5c58229ff9c2ff6d069efaf09177c08", size = 27569, upload-time = "2026-08-20T07:33:08.598Z" }, + { url = "https://files.pythonhosted.org/packages/88/07/c26b76debf0998ee08fbe947ab2058ac5de37d4b9d46b06c17abaa6c4ce9/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6376d4b3aca039375ca8bf92f770da0ec424a1ce3a37077a8d3c557411aa56ca", size = 27279, upload-time = "2026-08-20T07:33:09.518Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0d/ead6ddc029f91bc9b9390686dad3c808ab08100d348f6266b5f93f8970ee/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:9bacedc04b0402837586a17f0919e3dfdd95291f441f1f56bd80ec274c2840a1", size = 27774, upload-time = "2026-08-20T07:33:10.728Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/c108530d9eb86036b78d3af4de28b83b4a2d9a70512bd10ff8e59966aab4/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:76ae29acace5d33355344612844d588e19deaaba4639d8bb01601e4b1418ef36", size = 26933, upload-time = "2026-08-20T07:33:11.661Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/0bfc59e781c89acf64c31c388aade9d9d1c1ea38aa1ba1292fe07f607fe9/argon2_cffi_bindings-26.1.0-cp315-cp315t-win32.whl", hash = "sha256:df612391feca41c44d20118f3b88d1b86419465cd1f5496859f715ca60ec2210", size = 24875, upload-time = "2026-08-20T07:33:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/61/c7/c3e46068cddffccecb8ad94d71135e9bf62bbc789589e7dfadc7c6f59214/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1a0a29ed86960e44eaace7e081bdfab4f08b012fd96ec8edba71e2ad020939e4", size = 26655, upload-time = "2026-08-20T07:33:13.521Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ca/18b9c8c45fecf34b9100ec6d7946057f14a158f2eaa20ea123a3e82351cb/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d157ddfab1e8b21f2f1dedda9c09645d98b5ed0b667b0626be600a345d426440", size = 25376, upload-time = "2026-08-20T07:33:14.491Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "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 = "fastapi" +version = "0.141.1" +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/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +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 = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[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.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/ad/28ecd7cb894d172f3c9c80a075eeeb2017ac62e3632cee05a5f9493547eb/lxml-6.1.3.tar.gz", hash = "sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21", size = 4211198, upload-time = "2026-09-02T14:48:02.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/05/3ef45db776baea068044c799bbba68f3ca00a440c0e930a17c572f3d9639/lxml-6.1.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd", size = 8590357, upload-time = "2026-09-02T14:48:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a5/eee2fc77eee5ea68e4a4334b1def1781a3beaeefd3d98e81b4a38dc447b7/lxml-6.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1", size = 4632616, upload-time = "2026-09-02T14:48:20.745Z" }, + { url = "https://files.pythonhosted.org/packages/35/42/df27b56848acd29d8a720acc28977911aab36f2a09df4208d5502e887415/lxml-6.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d", size = 4936186, upload-time = "2026-09-02T14:48:22.94Z" }, + { url = "https://files.pythonhosted.org/packages/ab/8d/8a7b91df0b54d09d25f5f44885d6b3e0a6d6643a8c070191580318d20c42/lxml-6.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed", size = 5093324, upload-time = "2026-09-02T14:48:25.132Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/8f340ddcd43790332fb0de8a26628d571a492da3300cd191821698407c96/lxml-6.1.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2", size = 4998850, upload-time = "2026-09-02T14:48:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/9c5bb572f1f09ec9e4322bd4a4e9f4ad48347fc56ef94cf4df58a5279dc8/lxml-6.1.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8", size = 5626813, upload-time = "2026-09-02T14:48:29.61Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8bf1fd8bae8247743968bb76d027a1ac5bd2c4b44495fba6a71b30d10706/lxml-6.1.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e", size = 5232385, upload-time = "2026-09-02T14:48:31.969Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/6cef69ed81cb7df0d03b0dd09d08e6e2cf5061a743ff6f42f0b741548e9b/lxml-6.1.3-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245", size = 5347088, upload-time = "2026-09-02T14:48:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e1/8e5fd8ddc8c7d685badb0f2db149e3c9da84eefc2827c01c658df2c4e3cb/lxml-6.1.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0", size = 4707227, upload-time = "2026-09-02T14:48:36.62Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7e/00041382a11be40a88bf405ebff11c8efabd3de79f2691e1638b1c47a8a0/lxml-6.1.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e", size = 5240208, upload-time = "2026-09-02T14:48:38.893Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fe/316538b5cff0936fa63d45d421c655730fcbb5a28dcac728c175083002bc/lxml-6.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2", size = 5050271, upload-time = "2026-09-02T14:48:41.213Z" }, + { url = "https://files.pythonhosted.org/packages/c9/91/455bcccb3ac725373007344d351151810cd19762d1673b64b811f4359a42/lxml-6.1.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310", size = 4780433, upload-time = "2026-09-02T14:48:43.779Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f6/580440e2f52cf00bba5c5e1080bfa88cdfcde73be71a11d95170ddbb663f/lxml-6.1.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748", size = 5645928, upload-time = "2026-09-02T14:48:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/f6/dc/d123c1f244306543d545f62443f794959e4f1ea709fe100f8740d514e74a/lxml-6.1.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d", size = 5231184, upload-time = "2026-09-02T14:48:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/c3/3c/fe55b2bd5c6113c906511cd88f6a470195c5fbff1124f19970ab706c3477/lxml-6.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc", size = 5255814, upload-time = "2026-09-02T14:48:50.948Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a7/485df55acf55dc35e4ca89d2f48f03889e5a3241826b18b85102b32ce9d8/lxml-6.1.3-cp313-cp313-win32.whl", hash = "sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87", size = 3602214, upload-time = "2026-09-02T14:48:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/e46a7702bd95e9043291f7c3539b6184cba66f96cea9936f20939b284eeb/lxml-6.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477", size = 4004091, upload-time = "2026-09-02T14:48:55.699Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/154c78e20479a43916e63f19cb720d83f44f024b03228be44c92d9a97b24/lxml-6.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1", size = 3665468, upload-time = "2026-09-02T14:48:57.703Z" }, + { url = "https://files.pythonhosted.org/packages/0c/15/fc75a70b0af6021d0ea16811f1fc71cc42cd06ce90fe10f007a69b2eed84/lxml-6.1.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165", size = 8609725, upload-time = "2026-09-02T14:49:00.156Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/398fcf9018f881ec9aeaafae1ddd6586dfb13314a35d35e899de373dcae0/lxml-6.1.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d", size = 4639629, upload-time = "2026-09-02T14:49:02.81Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2d/49b6a6ad7ce8f64b07b9fe852ff0c6d3fcbb26db61bee4f63d4120180a1c/lxml-6.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e", size = 4965074, upload-time = "2026-09-02T14:49:05.133Z" }, + { url = "https://files.pythonhosted.org/packages/66/bc/6230cf80e4331c33383b0b6b73dc31a393dd76edd4cb73d761de5123034d/lxml-6.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8", size = 5099355, upload-time = "2026-09-02T14:49:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cf/d1143d9b7717e07a82f158a1fc9ce6e581fdad1226734950af869e3ffde4/lxml-6.1.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75", size = 5036795, upload-time = "2026-09-02T14:49:09.65Z" }, + { url = "https://files.pythonhosted.org/packages/31/6f/194bb00ffb89712c30f5a7e1b8e685590e140fad6c8261fec172c09a3dc0/lxml-6.1.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9", size = 5658740, upload-time = "2026-09-02T14:49:11.9Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/27e3cee3dcdb3b7bc09727b642bdbfcd098490ea77df04611db9060d7722/lxml-6.1.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0", size = 5245991, upload-time = "2026-09-02T14:49:14.154Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/8312560579fc980bbd2233a8a673cc46f7d613d3633f2bf08a21e8f4ad13/lxml-6.1.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6", size = 5354136, upload-time = "2026-09-02T14:49:16.459Z" }, + { url = "https://files.pythonhosted.org/packages/74/d8/eda60f4f73a9c780b5d6e1175484f66e6c81a2c93346e2906a1fec9c7a02/lxml-6.1.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023", size = 4704379, upload-time = "2026-09-02T14:49:19.032Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c8/c9cc60057be78ac34bd2b842e45e6e88edbfe5e532e82c3b82381b7aab49/lxml-6.1.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e", size = 5258676, upload-time = "2026-09-02T14:49:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/66894008fee8d1785b8db129747ae963fd427b68f456918df7f2f24a8b98/lxml-6.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92", size = 5090069, upload-time = "2026-09-02T14:49:23.562Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/c1b60404859f4c3cd1f41f29c65a24e25cea78fde822d9574a21f66810be/lxml-6.1.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48", size = 4741958, upload-time = "2026-09-02T14:49:26.037Z" }, + { url = "https://files.pythonhosted.org/packages/23/b8/6285f0cf546f14da2554cabdeaf7c2c2ff3190c74807f0de2e8810a786f9/lxml-6.1.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d", size = 5683245, upload-time = "2026-09-02T14:49:28.438Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f6/2168cab44336dcb15fed0f0b78577225b83297cdf0dee349c95420c3dcb0/lxml-6.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559", size = 5246087, upload-time = "2026-09-02T14:49:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/f5/89/32f5de69a0a31f30e6164981851f87b37ecb2c4ee838e504b88d49d4818e/lxml-6.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415", size = 5269352, upload-time = "2026-09-02T14:49:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a1/741d952ed3a7ef7a50055c6415aec3f067015e97f72f4389ce77b09657ba/lxml-6.1.3-cp314-cp314-win32.whl", hash = "sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d", size = 3662783, upload-time = "2026-09-02T14:50:23.751Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bc/5811cc73cac05e324e05ba9b0924e1a163a317a167ede8a9c748b11db30a/lxml-6.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861", size = 4073951, upload-time = "2026-09-02T14:50:26.348Z" }, + { url = "https://files.pythonhosted.org/packages/92/18/3768c8b01ac3a9bed1914715e6011711b00e2a11628ffa6f7fa37f8e0269/lxml-6.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376", size = 3749279, upload-time = "2026-09-02T14:50:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/72/38/84684784738d9451db2b330de2483f496690c3a5c642071df24135739b37/lxml-6.1.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f", size = 8860296, upload-time = "2026-09-02T14:49:36.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/b7/fc4c50bb1b38e864010ea396046cabe85129bf9e65b11edcfbc37d356241/lxml-6.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55", size = 4755190, upload-time = "2026-09-02T14:49:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/94/e2/ee9aa6ed2b666b2db1f6f7fd48964ff9da39ebe827ef5eac0ab881f639d9/lxml-6.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2", size = 4979517, upload-time = "2026-09-02T14:49:42.153Z" }, + { url = "https://files.pythonhosted.org/packages/29/e3/e7763d1661b283ddd4fa36f91b9a497db6b8d2aff55028b16c7f642e0755/lxml-6.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626", size = 5115270, upload-time = "2026-09-02T14:49:44.493Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cd/22205d5b4d177e3f4156f780412426ee7c7f8107809f119f0dcc40fa51e3/lxml-6.1.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414", size = 5032449, upload-time = "2026-09-02T14:49:46.841Z" }, + { url = "https://files.pythonhosted.org/packages/da/43/06a4626c3bb79ef8c501b674afab8100d64e798665bb2a97d1c960636a49/lxml-6.1.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17", size = 5603325, upload-time = "2026-09-02T14:49:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/733682a0c2de9f5779ba207bbb3f3f6be8c6bda863fc01739b186b38783a/lxml-6.1.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473", size = 5229023, upload-time = "2026-09-02T14:49:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8a/e69cdaca3fd33a647942925664f01b20908d41a6968c182305be9c38fb11/lxml-6.1.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37", size = 5317811, upload-time = "2026-09-02T14:49:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/0c397588174403c2ab68fc464abf97e03e7324f9c6cb6a99023104707195/lxml-6.1.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70", size = 4646516, upload-time = "2026-09-02T14:49:57.761Z" }, + { url = "https://files.pythonhosted.org/packages/56/7e/cfea25afafbe49db8b225764f7f74bb37c2a7f5e717d917d3d4a5e098ed4/lxml-6.1.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7", size = 5240626, upload-time = "2026-09-02T14:50:00.279Z" }, + { url = "https://files.pythonhosted.org/packages/a1/75/7a587771bb52ebb0e2c57b6dbe9fd96a70fbb54d72ddd97d54c5f8ec18d5/lxml-6.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2", size = 5086619, upload-time = "2026-09-02T14:50:03.245Z" }, + { url = "https://files.pythonhosted.org/packages/1e/01/94c0ebe6d831861542d251e038052e52bf6d33f1d18f1cfffdc82851065a/lxml-6.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c", size = 4758828, upload-time = "2026-09-02T14:50:05.873Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/938d67bd0e5b1fdfa52be28aefdffbad57e1f6b8e921c2aab88542c75f40/lxml-6.1.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8", size = 5627083, upload-time = "2026-09-02T14:50:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/d8/65/4e51522f6c214650db0abb7b16ccd11b1238b8a05a8d59aa4ebed59c9f67/lxml-6.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb", size = 5235170, upload-time = "2026-09-02T14:50:11.255Z" }, + { url = "https://files.pythonhosted.org/packages/92/c2/e73d19365665f6b16ef84df21199befc3b06e4c539046ad2d9595f6fb9ea/lxml-6.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8", size = 5252273, upload-time = "2026-09-02T14:50:13.782Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/7f386c84c9fe2854e1ca6e231c285e1c8f392971ac353c6865e6ec49faff/lxml-6.1.3-cp314-cp314t-win32.whl", hash = "sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a", size = 3902712, upload-time = "2026-09-02T14:50:16.171Z" }, + { url = "https://files.pythonhosted.org/packages/82/a6/8a3eb793f7900ef01c7f99e6f5fcbcfbdff35251cfaef66b32a4c16352d6/lxml-6.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2", size = 4400979, upload-time = "2026-09-02T14:50:18.621Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c4/3807bea283b4fe9e9d9f5dde46a73df91178472b335d2778e10b2a37aa22/lxml-6.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026", size = 3823401, upload-time = "2026-09-02T14:50:21.119Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/4614fcd65496054cfb7172662f3576a59200278739506433b8c241ea422a/lxml-6.1.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0", size = 8609378, upload-time = "2026-09-02T14:50:31.772Z" }, + { url = "https://files.pythonhosted.org/packages/f2/51/2cdce3c65fa99a6195dd8fbd512d33407c1000ad99f63e0a285b63d7a8eb/lxml-6.1.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9", size = 4640022, upload-time = "2026-09-02T14:50:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/0b30084e9eb1c546a4be3d9c56df70058d116b1a320400a59b0f7da87bf0/lxml-6.1.3-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79", size = 5037928, upload-time = "2026-09-02T14:50:37.007Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/5c37275a3e361f6138dc06db748ea565c1fe8a5f4ee5e2ddd80047c81a89/lxml-6.1.3-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015", size = 5661932, upload-time = "2026-09-02T14:50:39.777Z" }, + { url = "https://files.pythonhosted.org/packages/70/c5/b71ffb289b15e2642e2a3cf6d468c44da39ea119061a99e5b05e3d10f217/lxml-6.1.3-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a", size = 5249209, upload-time = "2026-09-02T14:50:42.141Z" }, + { url = "https://files.pythonhosted.org/packages/81/ea/9910da149a23932f9301652e57661cd9e42b0df18f12be21159b7255f92b/lxml-6.1.3-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed", size = 4704543, upload-time = "2026-09-02T14:50:44.634Z" }, + { url = "https://files.pythonhosted.org/packages/76/07/9290329cd188c62e22021f79df04ee0cc33d9a93b0d38bd65ccd452ad9d0/lxml-6.1.3-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156", size = 5261298, upload-time = "2026-09-02T14:50:47.301Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0c/aba78bd3401cd99b73a0aed8e2b9b43e14be94fab3603d4bbc8a62365f2a/lxml-6.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d", size = 5090453, upload-time = "2026-09-02T14:50:49.952Z" }, + { url = "https://files.pythonhosted.org/packages/8d/dc/fa4426c3355aa0216cbeb3911495b5f65a26e0df85859a89928fe28f0396/lxml-6.1.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0", size = 4744709, upload-time = "2026-09-02T14:50:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/be/2b/224fe7918658ab7c532ac2412f3c1eb28f71e6364fb07566262d0cc6a7b6/lxml-6.1.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69", size = 5685802, upload-time = "2026-09-02T14:50:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/21/44/7d480819b9adcae5f84dd8ac529132c6b7a578544398225cd20321adcd91/lxml-6.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0", size = 5249019, upload-time = "2026-09-02T14:50:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/385a267ea1b6b283f2249dd827ef360a295e9db14e13ef4665a120c60d64/lxml-6.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4", size = 5271886, upload-time = "2026-09-02T14:51:01.667Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/f967b0eb172ae876855a402d6d9b11fa86e3e0c89ca9bbfeadf7ffbfa719/lxml-6.1.3-cp315-cp315-win32.whl", hash = "sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4", size = 3662894, upload-time = "2026-09-02T14:51:45.173Z" }, + { url = "https://files.pythonhosted.org/packages/f4/48/d8a8c4160a29e663109ad520bac2deb37fcd014756d024561e8bc3e611ec/lxml-6.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad", size = 4074626, upload-time = "2026-09-02T14:51:47.77Z" }, + { url = "https://files.pythonhosted.org/packages/25/20/3e1395d34d19f9254625d0b567b81cf70d37d3417be074f4d63b94a2be3c/lxml-6.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758", size = 3749495, upload-time = "2026-09-02T14:51:50.663Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c6/7465ffd9c43883526a382df6fa4846c9d8d419214f7effbf65270e795471/lxml-6.1.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe", size = 8857677, upload-time = "2026-09-02T14:51:05.109Z" }, + { url = "https://files.pythonhosted.org/packages/ed/eb/1f3a917e299df43c8162c3e6f64fc2cea3bcf277910f35bff5b8e5d39901/lxml-6.1.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741", size = 4754522, upload-time = "2026-09-02T14:51:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f9/f81b4bdb6efb7a596be29603d8758154d00a5f545db9f3cef9d9041c8f64/lxml-6.1.3-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300", size = 5033744, upload-time = "2026-09-02T14:51:10.633Z" }, + { url = "https://files.pythonhosted.org/packages/c8/0f/26d9bfaacb319c86e0eca8a1a0bf1130d36a7afbd318883e23caea63763d/lxml-6.1.3-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0", size = 5615269, upload-time = "2026-09-02T14:51:13.357Z" }, + { url = "https://files.pythonhosted.org/packages/5d/90/73675f3f4141350ed65d6fec533b107d4e802c5caa340cf111771edd86e0/lxml-6.1.3-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd", size = 5236280, upload-time = "2026-09-02T14:51:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/fd/be/ed260767e7977de463a0f91f3f4fffcab85c0a2a024a21ffe1fa442c2c79/lxml-6.1.3-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e", size = 4650718, upload-time = "2026-09-02T14:51:19.102Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fd/e9839d03b1e767f2725cf7d7d81b80d5f3f9fdc10ad8827e2479311b046e/lxml-6.1.3-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2", size = 5243376, upload-time = "2026-09-02T14:51:21.606Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/4606e347e2788c301f677004aa83e28d24da9fe663a24380122af57be6fc/lxml-6.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a", size = 5092340, upload-time = "2026-09-02T14:51:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/ea/99/3314a8661cdf30f493c55a87db283961dfaae08451976a2ca418958e1804/lxml-6.1.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011", size = 4758768, upload-time = "2026-09-02T14:51:26.813Z" }, + { url = "https://files.pythonhosted.org/packages/30/58/3bdc577f78ea8b7d72d39a84506f7001d5b28728f43e5b84891e3b7d9a4a/lxml-6.1.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5", size = 5649546, upload-time = "2026-09-02T14:51:29.453Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e4/652633de1a2395949ebb7a8fc7d089aba12a2b45f0fefbc9d29e3e3ab3cf/lxml-6.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a", size = 5234874, upload-time = "2026-09-02T14:51:32.262Z" }, + { url = "https://files.pythonhosted.org/packages/65/a6/c4581d171de30449304b4859bbd3607e9b40da13c0f88b68e6097c8d785e/lxml-6.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887", size = 5260043, upload-time = "2026-09-02T14:51:34.841Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/ed6ee6186a89e69ca4ea9658b2a278f46a5efe8b5d4db56c7197f18653fe/lxml-6.1.3-cp315-cp315t-win32.whl", hash = "sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e", size = 3901093, upload-time = "2026-09-02T14:51:37.234Z" }, + { url = "https://files.pythonhosted.org/packages/67/9d/11d10257a4a048d04195d638bb61f0246ce2448eb05f682bcbab25a257a8/lxml-6.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6", size = 4395446, upload-time = "2026-09-02T14:51:39.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/44edd7de434181c582892e68d1ffe6775ca403ce14aea07cb5a218a936cf/lxml-6.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf", size = 3822836, upload-time = "2026-09-02T14:51:42.471Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "multidict" +version = "6.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/59/84b6cad9ddfdd9471db727b0e987c60ecbdb6b206ba265e8c50e74a1ab80/multidict-6.9.0.tar.gz", hash = "sha256:d7d32c0543494efbc9394e2b571725071d08e295993486bc9a43f6f89375ee01", size = 173221, upload-time = "2026-09-18T12:50:55.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/cd/d9475e63b3bf46bb369437323a5906f2442063812f4c7c892d39cda2a578/multidict-6.9.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:662315f8621b3559268813b11134c1f9633edd4ab5f7b713688a243c13f4026d", size = 61405, upload-time = "2026-09-18T12:47:15.908Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/69cfa797b3e5f85ebc45c39d5aca05f69afc95e23b22943ee57f9499ece6/multidict-6.9.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:4bef8cb5edea9c9daeb8396a75eeb97f912c3fb3fa408fe659edfecf91e47424", size = 53250, upload-time = "2026-09-18T12:47:17.254Z" }, + { url = "https://files.pythonhosted.org/packages/89/29/dd5d451e17dcec77f6b2dc0ed032714244d3562477349465dcc525f86297/multidict-6.9.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:75f7fc21ffce9a792cb919f0e0b0b52117bd672f2a55d1929d573b6fc437f374", size = 54521, upload-time = "2026-09-18T12:47:18.719Z" }, + { url = "https://files.pythonhosted.org/packages/93/d5/20d0b17d61a1fba726b96fefdaa224a2da04e66b0ad9ad08af248023bb98/multidict-6.9.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bb6be697065cbf31051465f9939d65624573d2219c3da8316db9e7cf5e0f5a7", size = 93484, upload-time = "2026-09-18T12:47:20.211Z" }, + { url = "https://files.pythonhosted.org/packages/c0/12/8067d5bd826b16eba31cb11b3f37ca6089d31b6afa33f208e77943f86230/multidict-6.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67bcff396d2ad62197c95488a716a88462792b384b5ea6e44bf7c1070eddb8fc", size = 57700, upload-time = "2026-09-18T12:47:21.668Z" }, + { url = "https://files.pythonhosted.org/packages/d4/da/ebf8f7e41e6bb58240ec346e5b8165acefd7ffdbce8ff503ab1bf2a1b883/multidict-6.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:beda95a6bd0a2e2265b941b90ff72543afbeaebb6a450e3a6a010d10d4a2ffca", size = 54762, upload-time = "2026-09-18T12:47:23.163Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d0/00ea9e7835ef5d61ce7d780d0aaca89a1d2215285937c9f12a089d1223a4/multidict-6.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e78870909e9f9e3d672ba99f1eb75130d7e01c42e642e6a70f434df32ca0ee1", size = 313470, upload-time = "2026-09-18T12:47:24.638Z" }, + { url = "https://files.pythonhosted.org/packages/81/67/1cc27283d5a8b92f499addf7f8b2d47af5528dd019d3375a49c58bccf8ec/multidict-6.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91092d597fcf0940cd6a59e64ba7156ee22d6899993fd7c5b1897fda71482f8f", size = 312460, upload-time = "2026-09-18T12:47:26.282Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ca/e5e9ff320467bfc5301499f6cfef049b427f9e2f757dbe2c2d63912c32a7/multidict-6.9.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4be612f23990a261060ccd8f15e89dad1f9b7bb0c1021c5c3987f5fc57959391", size = 297202, upload-time = "2026-09-18T12:47:27.885Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/53dd52b603ab0eb5a6b4c29992d902f4f76a802c192aae48f915e7bdb566/multidict-6.9.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7aaa14f0b9ffa2780c5d3b21da0e9b58778ed47af3369df72e5fd6dc10ff8041", size = 320331, upload-time = "2026-09-18T12:47:29.704Z" }, + { url = "https://files.pythonhosted.org/packages/54/f9/e9d3c76824ed6eb6f92a992e501f15c4daf1b01bba2451d0254c943c8eba/multidict-6.9.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97555ad30ad20a8eb90fa86522088eebbbd68aba03e56d53f4850b3535c65e61", size = 324944, upload-time = "2026-09-18T12:47:31.451Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/19deb0d051c694dbc2d5008ec89b341f7ee42c4412b38630a47a14339115/multidict-6.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c51fd8d59e72e45c64907bf7f81fbd94f4c8dcd1c8b2f23f4c2d7ede788fcfc3", size = 316918, upload-time = "2026-09-18T12:47:33Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/b4e3a2e62ea902f2f000cd7a8eee8da4f31c6137ed35446eed11d62ae321/multidict-6.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22067e88ff266e6a5dc59114529332692a01cc04b10dbdc2c14ab91217dee819", size = 279224, upload-time = "2026-09-18T12:47:34.624Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a7/656ae0635cf82d9bbd8742403c7cbebd5bf555e02686b5e1ac43a80268b8/multidict-6.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e231de8ce43d4fd10bec4f67f71238ed88a974de30a9f20be7a4ab16c970a988", size = 303343, upload-time = "2026-09-18T12:47:36.335Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/cabef61a7151451a7f153679a1dd8b9d31684a0f8edaad782da5f1900083/multidict-6.9.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b33e499a7b1f722547d57b4865ed68c03161643b9afe49fe097a0832078d183d", size = 302877, upload-time = "2026-09-18T12:47:38.323Z" }, + { url = "https://files.pythonhosted.org/packages/07/63/942663ac05edd16c96ecf71f89f2f632dc113192684303287d727e465605/multidict-6.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1eb7939025bd9289d9dfe3a399102b1642f4fbb105a6af82f284052ee89e97a9", size = 317489, upload-time = "2026-09-18T12:47:39.843Z" }, + { url = "https://files.pythonhosted.org/packages/08/d2/359d4ef44c74c8ae25099dc50fbbebd074cca913d49d713aad10ac83b710/multidict-6.9.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:51a08dceed4b42ef25755ee2cbf50df325e90e6b415d00955ffdea2c394ad6c0", size = 314278, upload-time = "2026-09-18T12:47:41.987Z" }, + { url = "https://files.pythonhosted.org/packages/37/fd/0b7a4fa989135045cd531c31c1adbb7ae762ee7143f5f1bc27985ae71d6c/multidict-6.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb847cb4002e725f88ffe29883da8290fd0754fa0461ed8988735e20964a9f65", size = 279318, upload-time = "2026-09-18T12:47:43.812Z" }, + { url = "https://files.pythonhosted.org/packages/78/08/7fd82e0e627b30733619c2d5ee52df047c608b562232e501835d645a5384/multidict-6.9.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:516fa4817cd070088f616a901380db56189f86daee9da79aada3c9b653f49ed7", size = 315045, upload-time = "2026-09-18T12:47:45.659Z" }, + { url = "https://files.pythonhosted.org/packages/b6/99/08c09a9aa8797246bf6b646e603654a8f25326df4881855da4b9b10a5e56/multidict-6.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:254e53be2ec70518bb82dfa9b0c7166baaf2bd0e918bd17b65caaf489d5a5522", size = 313360, upload-time = "2026-09-18T12:47:47.503Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/72f0c58d20f3e8d167105b0ab3e8b4a9e3d7fd285b9f6fe4f1e1295ad002/multidict-6.9.0-cp313-cp313-win32.whl", hash = "sha256:9869105ab61db13004f9ed610cd29ce4237b2c609f5f7b8bb8f2339fb9dad17d", size = 50744, upload-time = "2026-09-18T12:47:49.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/51/384557cf30472e760ab9e897105f3a4dc7bbb8162e4a60695008ba012c40/multidict-6.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:85ed0f3c3b01174aea5a8a8fcd456f64c2e9719c0f42c612c2694ecd35974a65", size = 57094, upload-time = "2026-09-18T12:47:50.611Z" }, + { url = "https://files.pythonhosted.org/packages/59/89/8a3741537d155588cdf220bba1f1e5c96ef2e92ee1f41856be76237eafc2/multidict-6.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:1126782e3c3b1a7ccd990be3d3221709348e4b07d6ecf8b50965a93ae2624145", size = 53342, upload-time = "2026-09-18T12:47:52.15Z" }, + { url = "https://files.pythonhosted.org/packages/83/35/41bfe9812c1efc85b2b1504c4fbaf19574be11d18df1f08abdd489280efa/multidict-6.9.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:1b34b10b615db5a3ed424b174a362a3cdf06220bd892d03d151a619bb157c4e9", size = 60487, upload-time = "2026-09-18T12:47:53.713Z" }, + { url = "https://files.pythonhosted.org/packages/61/98/b5e73e4af8c6df0b2c9cdab4b2566dcd5185fb4a345567e51ea2008008eb/multidict-6.9.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f7f0b996a20776018a253061a781ba983e7b944e17a0748822026048b8b0bb07", size = 52425, upload-time = "2026-09-18T12:47:55.285Z" }, + { url = "https://files.pythonhosted.org/packages/62/f5/9f6586f01b458af9690490f147f5d011bda35dca8c5887258a9c7cc35d63/multidict-6.9.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b612842f7f286c42abc5dcbda1db50e9dbed39720dd15feb07dbc9974bfeaa7c", size = 53691, upload-time = "2026-09-18T12:47:57.05Z" }, + { url = "https://files.pythonhosted.org/packages/f3/79/532ef4caa1f9fd3573751821923fcdf1948f6b4648c96378bf9042340596/multidict-6.9.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:71ee2b3198323cbff7a846538f01599f309dddddba79b5bf5a3dca2e49460ef6", size = 91804, upload-time = "2026-09-18T12:47:59.033Z" }, + { url = "https://files.pythonhosted.org/packages/28/5d/40602b534c8b731619a39b3cf1e8f41b25d81e125af3915b858bbc254de5/multidict-6.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d0dc1f9c7dc07d86e5a4ed24f3a55b945106f3e7c70a984ff8dd9267270414ed", size = 56892, upload-time = "2026-09-18T12:48:00.59Z" }, + { url = "https://files.pythonhosted.org/packages/c5/dc/9876ad169f03f1d6163dfb5d9b90913c9213335c19a61f206a0b75d62815/multidict-6.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a26c8672d70d2c12c2f1c4df91fe92422e905efaf1d639bd4d7192868a21e711", size = 53936, upload-time = "2026-09-18T12:48:02.684Z" }, + { url = "https://files.pythonhosted.org/packages/18/75/f8e9e24de0d4d085632e867c54606acb97920b7a5c9953c0947fdbaebaa2/multidict-6.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:186cc28558e3d6921ba797e433cd58ce64eb8b2f553e93f75dccbb9a604b7457", size = 315198, upload-time = "2026-09-18T12:48:04.272Z" }, + { url = "https://files.pythonhosted.org/packages/e4/eb/f6a43d347b3b60184d4058cff8b1e611529248bee210ab78505d0f2c023d/multidict-6.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebc02af6ac170631774219c26fd63b4404d1e836a1aa92b738a3127eaa2aebad", size = 310536, upload-time = "2026-09-18T12:48:06.323Z" }, + { url = "https://files.pythonhosted.org/packages/60/0a/65fcd25d3f4b2972bea7c119fe7142a7e9e643f687a3f1926dcc5624d87e/multidict-6.9.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62c4ffba9f81a2b17cf081bfe4e6b7eaf17193dc17d5870c01b2763e48efed24", size = 287297, upload-time = "2026-09-18T12:48:08.41Z" }, + { url = "https://files.pythonhosted.org/packages/87/62/e6be8a2491810ee921f60c730b7d5275b7f5212927022fa599a1c822df98/multidict-6.9.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a7e2aaa0e9a6413b54cd90fa0fe5944487872a7130a1c07347627974e677a34", size = 318886, upload-time = "2026-09-18T12:48:10.135Z" }, + { url = "https://files.pythonhosted.org/packages/c3/5e/f1702e7bdce29893f05d1596c23b4eec791d89d7d69786aa4be34f7ebf68/multidict-6.9.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:26e34dbbd6c3438ee19e0db1f5b846a7ca957085d193ec312b488924c3941a0d", size = 321460, upload-time = "2026-09-18T12:48:11.892Z" }, + { url = "https://files.pythonhosted.org/packages/54/86/ee493f4c319f998f48abd928a6c7b6f0d53604fa1b5ae53b23400cdac078/multidict-6.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d811799f821c06883404a171faa880660ef04c2687fc32fdaef22fb645fe80", size = 314222, upload-time = "2026-09-18T12:48:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5a/a4cf1aeb9e2d17cac89686283638d89cc3dc49e9ae36c0e99fd9b5e8d9dd/multidict-6.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89fde003cec43783193360c27de2fbb0c8ea2db1cfebc193c3fc657c436f9d38", size = 279835, upload-time = "2026-09-18T12:48:15.288Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/d7e55edce6771e38c88204939720c022dd0027a2d7c110515a6f378ce76a/multidict-6.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9bfa4ba6d97ae6d9c1f5336c2abad5690bce1802a040f9aa5fa39cb0b3099c02", size = 301182, upload-time = "2026-09-18T12:48:16.968Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/546c57c112860ea3c06d429f661a2e95027a7429813dad7f9bd0db7b41cd/multidict-6.9.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a776fbaf700549943b647f21479d69191c238c84fb55d5a48eec1b4cf6cf6e00", size = 295357, upload-time = "2026-09-18T12:48:18.872Z" }, + { url = "https://files.pythonhosted.org/packages/20/64/6ed29fb8d80f806e2350ec84da14d18f079afa5be1f86529a30139dc5815/multidict-6.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ed04614c47379bcdfa01d3e165149518017ea034cd5f48e203c287cc3d2ebd4b", size = 315608, upload-time = "2026-09-18T12:48:20.564Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8a/a636222fed7db139b474bbe726bca1bb615bfe0f59bcc880bd2590d05a93/multidict-6.9.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9c65d9912983991f368bd01f993191b1593c90ecde6be990d73add46084d235f", size = 311612, upload-time = "2026-09-18T12:48:22.296Z" }, + { url = "https://files.pythonhosted.org/packages/91/6a/d475f18e6fa223d46a086c0ba6b3449582305e1a8719137b8f5df30e5eca/multidict-6.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4b89b102be78ed286123e75a30efda519e1ef6b9d4a4d01a08213f5e01a18e45", size = 279440, upload-time = "2026-09-18T12:48:23.973Z" }, + { url = "https://files.pythonhosted.org/packages/8e/70/1eb5c630e99f8b32bdc030a46c3ddf5830a8daf38cb18364e5d75872470f/multidict-6.9.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e3ed6563ea143214c63bb4452ae5e8787a39226b22101c179fa4d2eda5972267", size = 312327, upload-time = "2026-09-18T12:48:25.837Z" }, + { url = "https://files.pythonhosted.org/packages/61/aa/b4dc4421596816a4444f8dd8ec25e294d47ff385feb01367941c96c6b650/multidict-6.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:448c05bd13f8675b3629fbc3fabb9e7bb19a5636a7a3eb677ea25a08d0544a56", size = 311385, upload-time = "2026-09-18T12:48:27.668Z" }, + { url = "https://files.pythonhosted.org/packages/06/5d/afea7cf18163a56c3313bee7fb62771dc570a9aea193278e5d071f9b5fed/multidict-6.9.0-cp314-cp314-win32.whl", hash = "sha256:2b5db4f2ca34fce55d34b9c66a0eb5d0d01962e2420a1218ba836d8e46aa6ea3", size = 49929, upload-time = "2026-09-18T12:48:29.779Z" }, + { url = "https://files.pythonhosted.org/packages/96/07/f20f633396700043a804fc1d7c397928e79d9955516b7621639219c6aace/multidict-6.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:ccf9f7f07e9ab6e24f68ccb2b4da8ded65c1fbc5390ebd897a5fe55cdd4c064f", size = 55765, upload-time = "2026-09-18T12:48:31.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/99/d1d5403e8c13bde35e4ca116213a5ebd021d35dcf546b18f5f6a38c77cc1/multidict-6.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:64ef289ca3c6eaafaa705978686631cb5acc90a92a09bef39c95a810c5f4b804", size = 52025, upload-time = "2026-09-18T12:48:33.037Z" }, + { url = "https://files.pythonhosted.org/packages/1d/49/86d2a4ce9c034a16469096627dfc03a2c1015a21c976261bd29b80548543/multidict-6.9.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f17f485e1837a7d31216be8be991a51265bcfc3cfd2457d5c94ade6086793ad7", size = 104111, upload-time = "2026-09-18T12:48:34.741Z" }, + { url = "https://files.pythonhosted.org/packages/ef/94/af88a68c4536d791b9224749051856f2f8f6641a2470b04efaca22d6d548/multidict-6.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3fccacf47e756a0f487b2b7e4afc9f167db0770ecc4e887b38ae2aec99dcb510", size = 63316, upload-time = "2026-09-18T12:48:36.394Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/fb3f5fc6d108dfd32660559d8247de0834a8654f7cd0506c27881dfd9709/multidict-6.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:527655c10fca73fd336cbcd5ccaaba5d8ded1512a3183ed7ebcc1569db5f1de3", size = 60593, upload-time = "2026-09-18T12:48:38.136Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e2/66e94b3ee770d5719a5e85f3c5a2f35aebd27a27025655c9694e80e9c90e/multidict-6.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d3520f774e78dad488c97398e5c7253333baa4eea09785aca56d477c4dc05c7", size = 263846, upload-time = "2026-09-18T12:48:39.909Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/f888602d08bd861a8143c00a6cb85aac1c0f9fcfc917948465d2908898d4/multidict-6.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:518a76bff2567fb0941603b9ccecdd830dd2d717605e7a28c1edc9e64688759e", size = 278411, upload-time = "2026-09-18T12:48:41.735Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d7/0f0f4ff3b27f9083a5c949cba860dc9552ca7a74a56621b95fcd1fe9e5d1/multidict-6.9.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ff308def558cbbff74ea889ec659d117ac550d4d554467ed6cb3bf6adadac7e6", size = 262973, upload-time = "2026-09-18T12:48:43.622Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f6/8b70937adc0b0fa528544ce5ab98ad9dc8ba27b401b9ec8caa359fe8e394/multidict-6.9.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:875a77927cd7bc0bc1d0a0520a7ed2267bb169c5db03cc133eaa746526d4182b", size = 287097, upload-time = "2026-09-18T12:48:45.331Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2c/ef888530e3ec279dd24fd1db34c027721b8e2aea8485bef99e7464cd77d1/multidict-6.9.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2182da6ffc9793dd66d7d3632eea2b131f1a1e26eaedd51956845501830d84fe", size = 288527, upload-time = "2026-09-18T12:48:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/99/b0/8ee78b0fbe5fe153e9566055e1107c9bae0011b1ae903e30c7ab2ba3e07f/multidict-6.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e680a0b07e44b87a975eca63f84860a772667b0037066a6e93d998eb41ad2814", size = 273078, upload-time = "2026-09-18T12:48:49.132Z" }, + { url = "https://files.pythonhosted.org/packages/25/a0/9d0523f08827a686b80509f4f8f1b484f425f1586b951188cfa0f30f1ef2/multidict-6.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5066b7877e1e2ae2c3712c8037eff40a80d4cf35b393b4c42c81f1c4b73b346d", size = 249874, upload-time = "2026-09-18T12:48:50.954Z" }, + { url = "https://files.pythonhosted.org/packages/19/77/9e5de655bc7a56d48789bcb5cfe49a89be952801d18cb3b6523190648cb4/multidict-6.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fd0ec96e9877cb4dddf7942dffa777837809663f8e55d4f79a2bad06453446b0", size = 266815, upload-time = "2026-09-18T12:48:52.787Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cf/2352eed7e7984c44f896fe2034e58cd09fdcc9dc8c2385fc6177f8c00159/multidict-6.9.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:98b0738ac1a1593f609a7b67d25e09c7de57c0f91c2879eb01c46b2dae55c0ba", size = 261108, upload-time = "2026-09-18T12:48:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a0/878e80568b168f4c623b5c3cb4d848d105e99cf9b4979f472ae7c60c2450/multidict-6.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0548fdffa634c495b866d89e02ae58a9588cedaeeb972f6ced433e20dee1db5f", size = 264379, upload-time = "2026-09-18T12:48:56.726Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3f/3083816fb153cd5b7261d26160624d9289d8d0b087a9f125f4d184a86cbe/multidict-6.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:7d0649ae61a341b9a47a2afab57424bd2a0ce98d36ec170ce9a51ce92c7284f6", size = 277516, upload-time = "2026-09-18T12:48:58.775Z" }, + { url = "https://files.pythonhosted.org/packages/c8/76/55f4e30d2fb96d52e3a3d60c4aa2bd6ba6bd6bfd3ac0aa76e303e19574ca/multidict-6.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c6d470d693ff45678b9ab237113546f6dc18918be55b3c852a69ed05ae7404a8", size = 249342, upload-time = "2026-09-18T12:49:00.645Z" }, + { url = "https://files.pythonhosted.org/packages/09/b9/bc9f88e51df48537105e3c6b2a6569c2de1a4deac6c7828ccc541e170015/multidict-6.9.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ac05ce26d2c902d2eb5afe70f2012d19f3b9510ed5ef054bab27a3d43bc7f39c", size = 274843, upload-time = "2026-09-18T12:49:02.927Z" }, + { url = "https://files.pythonhosted.org/packages/60/ee/8e08e89b2b699590b5ecb8095581cb6e5389be5d985b16addbd99839065f/multidict-6.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e82ea3ca4409c20172074f27dc1ba5858174f143d0ee637dd058b8061ff024c6", size = 268912, upload-time = "2026-09-18T12:49:04.992Z" }, + { url = "https://files.pythonhosted.org/packages/34/fd/f7d3e7112c4eb6f45ffafd4447fb549c1d42abb4e723db12c7158dc3e0ff/multidict-6.9.0-cp314-cp314t-win32.whl", hash = "sha256:d61680c1969b5b1b900fece510077e7732336eae02f68dda02162e04ff6ff321", size = 55241, upload-time = "2026-09-18T12:49:06.83Z" }, + { url = "https://files.pythonhosted.org/packages/3b/06/fcffb708fcb4f8fba0cf399af19e15d878d59dd76878d4b3f34aeba98c52/multidict-6.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9a9af5d28024c1de9e0efe06a64b5a8561e6517daa66788169e60d2c588e15c5", size = 62135, upload-time = "2026-09-18T12:49:08.76Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b6/22923e7d3897b4e455c507d21695894084f6cadab8cfeaf9967e37401cf2/multidict-6.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:810ae53369d957fcc3e3855612e3a35a39960adaf4b3b1cb4cb4a2e94185bcfe", size = 56193, upload-time = "2026-09-18T12:49:10.866Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7c/db9f8299f793ab605a0a4ad6cfdc1c8523277e08780f11b4a24a4ff84186/multidict-6.9.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:b9bcbd708bb5e1ce8cf9c58908ae9777a90f777406315b046bb10cb3e87f8621", size = 60490, upload-time = "2026-09-18T12:49:12.922Z" }, + { url = "https://files.pythonhosted.org/packages/15/16/446e15381051737dcc08683bcce29b33a0527466807931a04c12c25e3d56/multidict-6.9.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:27ed5aaee62eeff74786a3259de81ada4f2bf84b75122bc06d7d8eadc1be5643", size = 52464, upload-time = "2026-09-18T12:49:14.902Z" }, + { url = "https://files.pythonhosted.org/packages/a4/08/8e3e51c02dc91b700addedf959bc850b0d7fd1e350188304f8e6a4414400/multidict-6.9.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30ba80aa7ec7340e80140d29f563e33acb21f3756e42df13612f2a26c80c90ba", size = 53827, upload-time = "2026-09-18T12:49:16.855Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/750bce1c2ea696c5c19644a537415b930c30a463d1c558dc48d14e45bc52/multidict-6.9.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c26e56c68bde47f8028265b3829915450c944febc75a1e4d0365b8a75557861a", size = 91821, upload-time = "2026-09-18T12:49:18.833Z" }, + { url = "https://files.pythonhosted.org/packages/44/40/35d4bd06f49e872fa277ed2aa38eb13b61cf0f1cfce2cf9050899e3f1eae/multidict-6.9.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:f6705ae71e4b14c693877be3cc09fc62af10197ca08ee08bb57ffce6e0f9c5f4", size = 56892, upload-time = "2026-09-18T12:49:20.844Z" }, + { url = "https://files.pythonhosted.org/packages/60/4b/be1416c1fb3e8c273155a46179f2a5055c35db524314776675278d01d425/multidict-6.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:1fc7b1ce735737b7abf38935928729433ef2c81306dcab7f360ef3c3076bc8e9", size = 53962, upload-time = "2026-09-18T12:49:22.863Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cb/dfbdf4523ffeb1bca192c9b3d429ded754946922c57dfed40e6e489a3307/multidict-6.9.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b7731a591920d77bd9372a9a8ab3f447129dbf17d29b10928c94005e1606641c", size = 312140, upload-time = "2026-09-18T12:49:24.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4e/636674d1dd0acbcb2216dae1f82640835b7b738331d81a13bc442194a816/multidict-6.9.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69a9295177c33756d7a6f8da0f1db345ac951f2436be6ad4b90f53056526e767", size = 310883, upload-time = "2026-09-18T12:49:27.376Z" }, + { url = "https://files.pythonhosted.org/packages/dd/30/f7f0aeebb9268981f9b76171adeabff078d99f7d0efa9d1da689927eb967/multidict-6.9.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7b4ae468e341cc84c1ae035ee12f032cc0716f618b1eb417fb14e8454a17c620", size = 290430, upload-time = "2026-09-18T12:49:29.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/4a/d00b3307561bb9ef742cfb431582a25491269a538187cf387daa1362b2dc/multidict-6.9.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb9f52b89e339007a06c88cb95068e9d612ce252baaaa5ece85de96e2d51fca9", size = 319088, upload-time = "2026-09-18T12:49:31.836Z" }, + { url = "https://files.pythonhosted.org/packages/19/9e/1671a2cc29841b9ae9521a35b21f6aea9ee27ae5b6ed4b75889b096f89df/multidict-6.9.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69190050f74fe139a7b3759c730e4e87484db9cf74b1786910a24496ab7a40f9", size = 321532, upload-time = "2026-09-18T12:49:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/76/77/0f8a7b2155d38cc9d22b033bf7e0ef1d0a44947a5a4852c9810f91bbf8d4/multidict-6.9.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5f04bbfb064b87455a8012f5829b20201d608cf4b3a1a0e50c7a14173a75109", size = 315859, upload-time = "2026-09-18T12:49:36.421Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/96cdb8ea3813116736b47097078e40977c1c5a71f02c693aa18bb4f30f59/multidict-6.9.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d81d0ec43a73056458e844cf90239d2c3a7aeb4ef05fea8c0f64775d5111847", size = 283500, upload-time = "2026-09-18T12:49:38.871Z" }, + { url = "https://files.pythonhosted.org/packages/30/13/1710f1847cbabc2104440e994b1f789fd2581a03445ca45f10e75e64aa76/multidict-6.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:b8aea3d54f3cc20a557fb951c68294d9aa88d614cf7151aa5629b1d5e4033b74", size = 301639, upload-time = "2026-09-18T12:49:41.115Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3b/89802c789a3e90923b872d5791a523729ae9f9d3167789aef63b0e41f4de/multidict-6.9.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:31fd2961bd754ac3177aae9438c01becc22eae029911f13c558e5767a10e5dc2", size = 295976, upload-time = "2026-09-18T12:49:43.412Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/c27827afe584ed082eca0afe0ca10f780f842da4e6894c1aee515526e44f/multidict-6.9.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:04e6f4eddeff93c778a9e7137568284f28856d960275085fcbd87cf77a53a057", size = 315167, upload-time = "2026-09-18T12:49:45.641Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/a6fd940d4d9490f5249b378391fc7b51cb79ce962bcfe50866ce7369e76f/multidict-6.9.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:319160c73795ce31e86494773e0f12c446d32dae7d1a76f4829007ef69eb80bf", size = 311804, upload-time = "2026-09-18T12:49:47.858Z" }, + { url = "https://files.pythonhosted.org/packages/b1/44/14b1cdb66e35ad8f29ac7372c4ee178c0fa350cedf5280623e9ab9ff9185/multidict-6.9.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:0d7fbd38d52119423ed8c69672ca47eac121062c30c976e00ba732d114774874", size = 283329, upload-time = "2026-09-18T12:49:50.011Z" }, + { url = "https://files.pythonhosted.org/packages/b7/0b/6e584b53ade95ca42160716db6fcb7c660f8607bec4c884fece84f5a6fe3/multidict-6.9.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:981280a946256fc151584fb3b82b99db091fccac81cfecce530585db559ec62c", size = 312161, upload-time = "2026-09-18T12:49:52.303Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e3/a2c9b107d5a08e2415372ccea189ec23a2ec399e2de48b97eaa48a298405/multidict-6.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:fb3d1f3c63d49656a83e1604ef3ac573016cf2e62da84d1937f47107d5689c8b", size = 313129, upload-time = "2026-09-18T12:49:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/54/86/b2e3439e7898e18aed90c81da53a7eb71e77b0b2647813fb45e8b96044e1/multidict-6.9.0-cp315-cp315-win32.whl", hash = "sha256:0c5388c41bc7c551548c4ce840349f0708f7ced6df877d82900bac82268160fc", size = 49919, upload-time = "2026-09-18T12:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8b/d7069d710ff21b627054b4e33dec8ae4f0d5493858d1d4947d53bad3a7a1/multidict-6.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:49e47de3823bee4896f1eedc17ce4fc4f42c433bab168ece7911f42e69a4f666", size = 55763, upload-time = "2026-09-18T12:49:58.773Z" }, + { url = "https://files.pythonhosted.org/packages/a5/df/8d50fc63dd34f88f614b6188e9e75d71a777151cde436952d06af962e7f8/multidict-6.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:1ac339b944616d4061a9ee9571c89767543db38c19c8c7917a9c08b1462c4800", size = 52035, upload-time = "2026-09-18T12:50:00.919Z" }, + { url = "https://files.pythonhosted.org/packages/97/30/beeb05d09787cfda7d4b926119ab211b20fa6ffa0fc1ae71d32a665744ae/multidict-6.9.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:cb6be563dd43b2d4d528afded1878b559ef1c2f45f89e94d273ef515485727f9", size = 104140, upload-time = "2026-09-18T12:50:03.32Z" }, + { url = "https://files.pythonhosted.org/packages/49/73/0dfa03c23bf73f4ed09d2ec831b6049d514aa0f4d2e3d11641458eedb2ce/multidict-6.9.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:76f0df301516563085342d9cdad03fc3c0301b36c3cfa8454e49ca73878aa59c", size = 63291, upload-time = "2026-09-18T12:50:05.65Z" }, + { url = "https://files.pythonhosted.org/packages/8f/87/0089f0b1660f43f9efb5a132a2fe2d81dd2539a5d6c27993fb68ec89fe4f/multidict-6.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:34c00a534e948eb5f8b3c50cacea9daa4fc4dfd503f6edd114bb532f53742c9e", size = 60643, upload-time = "2026-09-18T12:50:07.784Z" }, + { url = "https://files.pythonhosted.org/packages/c4/16/0ede7577da32830a8d80df3874cc50234a725acb0cd27ee2b0abd01015e3/multidict-6.9.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cce5e33383904e32b58bef7765c38917899a9dc87058f75c421c82e7c11440ee", size = 262750, upload-time = "2026-09-18T12:50:10.053Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/4da6b8477cb8a6cdc79ea19f1514ab3bb664635bb3c9e0ffdc6867d4f155/multidict-6.9.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c375408881861294207c1d038a711120776989c5e8c489b251a818cd74d39886", size = 277978, upload-time = "2026-09-18T12:50:12.638Z" }, + { url = "https://files.pythonhosted.org/packages/69/f6/702e239743ce061e20dfa29e127bbfb7df7150d449e5cb410cf9cdaeb1b4/multidict-6.9.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3f95940278766f273b668bbd9dd412ab5dd170ff40fc836b714c38de7852f1fc", size = 264601, upload-time = "2026-09-18T12:50:14.933Z" }, + { url = "https://files.pythonhosted.org/packages/85/1f/2f049044fe4c7944251b34776a11e1708cc5d0911f4a298de257e2765cd0/multidict-6.9.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6009b58d6994881c53f56d2f2010f0b3cd30139c7b14156c2329d00c0d592e8e", size = 285387, upload-time = "2026-09-18T12:50:17.156Z" }, + { url = "https://files.pythonhosted.org/packages/75/3b/0fc08a1bfd7a304c54b2071600cdc1ea4b59601050f5866c5f67206e0439/multidict-6.9.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7d5ab831301decfb7c29d1c243823f6ccbb3766117c5957bdc5b9f283355d0f1", size = 288613, upload-time = "2026-09-18T12:50:19.675Z" }, + { url = "https://files.pythonhosted.org/packages/be/4a/0369b357785000590801bc90925ff1fe0e485d0684172a2433f801f11d3e/multidict-6.9.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17b74ffc94e63843cf34b7eb874ab1653afac0eda3edfa3de1207db7fc8c0ed9", size = 276891, upload-time = "2026-09-18T12:50:22.014Z" }, + { url = "https://files.pythonhosted.org/packages/33/bc/23aed38c0e79532b1dcce93e13c01d31e8a18cbe85503084b28550a47961/multidict-6.9.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62dc471198ac2f1085f6c6ba9aa5ad3343c0cbac0756d2fbe551659d55e14212", size = 251973, upload-time = "2026-09-18T12:50:24.452Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/dfc138bc987d2981cd9bcefcf67876155a2122644bc74fa87c4df2056b20/multidict-6.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:da97c0a501c5da00d9c3af6d481ed97dace148f9030f4f5578534981d58682bb", size = 266218, upload-time = "2026-09-18T12:50:26.849Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c9/7ce37abc93bf7618f18f1133083b2c14383fdfa9166a1e8756ec3a84d163/multidict-6.9.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:986f2a23330e8823b389872ae63b83b862a10e63e8cfbd11b42ab8664d1eaa7e", size = 262464, upload-time = "2026-09-18T12:50:29.328Z" }, + { url = "https://files.pythonhosted.org/packages/e0/8b/a7d25bbe0fe7d7e4a1ded661063b8465270005082d4dc258488b268ad20a/multidict-6.9.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:ed96992cac94b32a9c3da5870b05967271397a813029d68197939397860b1470", size = 261499, upload-time = "2026-09-18T12:50:31.83Z" }, + { url = "https://files.pythonhosted.org/packages/27/29/cecbae0a00c022f9f3756f02f99d7f5d2da637aea0c5ad5102fb3a65bfbe/multidict-6.9.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:94e767d30be457da0ef134e688cd2b6a438d19b85bf000b1c4a2b9b6f55b4211", size = 276001, upload-time = "2026-09-18T12:50:34.274Z" }, + { url = "https://files.pythonhosted.org/packages/3f/91/ef23ee6a67ff556b8fccf4aad1dcaed8921a99e63528781bf116e0f62f6d/multidict-6.9.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:d1f98df09bc22d298d88dee07eb5365dba1fbf3b41e876a6edb2652a8b3a6ca1", size = 250967, upload-time = "2026-09-18T12:50:36.693Z" }, + { url = "https://files.pythonhosted.org/packages/01/8d/92d7b97bd55aef4b37503fa7b622857d7e579e2303a40210b7089f7e3dcf/multidict-6.9.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:c5290038269f7d96037afc47b05b286e4791adfa7046911e2a0483b6220c84fb", size = 276286, upload-time = "2026-09-18T12:50:39.342Z" }, + { url = "https://files.pythonhosted.org/packages/60/04/f9a043a7b3513c29315cbf3740a2b94bd0272ea904c844ac8eff8a3d86f8/multidict-6.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8858d2ddd0bc21b4aaf189016c95840b29ddf0236a049af70a9cb4379bef9abc", size = 272479, upload-time = "2026-09-18T12:50:42.008Z" }, + { url = "https://files.pythonhosted.org/packages/1c/25/e1ec0c14b2fd3d4f959913862c88b0dd5748ba10ddca96411b17641d8bf0/multidict-6.9.0-cp315-cp315t-win32.whl", hash = "sha256:f90724dade5865b1c9429d9b058f11096e44a7f1f0e0a21eff18d98ef32c22aa", size = 55280, upload-time = "2026-09-18T12:50:44.265Z" }, + { url = "https://files.pythonhosted.org/packages/03/55/8b81e62363ae0721b2735bcaa111c1e08d30d4d0680b591bfd3b7305e9ab/multidict-6.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:150f1e5bcc14234e99aacc931deafa52713157df947efeecc1a3b7601f2714ab", size = 62222, upload-time = "2026-09-18T12:50:46.979Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/fb518ae4d302f71a1e96496bb011409d09736a91aadd3a6ceb66a7a2f259/multidict-6.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:734c985bbd60e84d0ea523bafe139394e46b8a3f35754a70111dbcbba243f7f4", size = 56365, upload-time = "2026-09-18T12:50:49.649Z" }, + { url = "https://files.pythonhosted.org/packages/75/2a/557689d56936a83c112ee28f4c4d7698ff01c24e22d217ad8fcce19982c7/multidict-6.9.0-py3-none-any.whl", hash = "sha256:57c2445049f7d8e66306f712868219da7ff7168ef42263dc032401211bf1205c", size = 19175, upload-time = "2026-09-18T12:50:52.172Z" }, +] + +[[package]] +name = "ohm-stream" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "aiosqlite" }, + { name = "beautifulsoup4" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "lxml" }, + { name = "pwdlib", extra = ["argon2"] }, + { name = "pydantic-settings" }, + { name = "pyjwt" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "sse-starlette" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "aiohttp" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosqlite", specifier = ">=0.22.1" }, + { name = "beautifulsoup4", specifier = ">=4.15.0" }, + { name = "fastapi", specifier = ">=0.141.1" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "jinja2", specifier = ">=3.1.6" }, + { name = "lxml", specifier = ">=6.1.3" }, + { name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.1" }, + { name = "pydantic-settings", specifier = ">=2.15.0" }, + { name = "pyjwt", specifier = ">=2.14.0" }, + { name = "python-multipart", specifier = ">=0.0.32" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "sse-starlette", specifier = ">=3.4.11" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.53.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "aiohttp", specifier = ">=3.14.3" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-asyncio", specifier = ">=1.4.0" }, + { name = "ruff", specifier = ">=0.16.8" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "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 = "propcache" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/4c/3b1365d58a667689e067e13d055fcd92bdf8d9a2fca3d9201b47ed5b3631/propcache-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36c0d9db44b523ef93d03341b1c42d69ff01d673c053d1b1c6c3a363bcaa39ba", size = 85290, upload-time = "2026-09-16T00:14:52.342Z" }, + { url = "https://files.pythonhosted.org/packages/8f/61/5f9c29c3aa67c30238c4eadf95149b1d983a48f69b86b0cff927a7d6df13/propcache-0.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1d52a05dc417279f7e5c7618c5dfbbc29923aaf9bc0a5c1802ddcebf54c61a0", size = 50027, upload-time = "2026-09-16T00:14:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/25/7d/c1ab1ef09e9d4d835be5d58c0a32a1e1de8397abaa4e502a9d4141328cad/propcache-0.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44149f46500a0a41b95b4d99c2e586a77319539730607b9892974a092788b111", size = 51425, upload-time = "2026-09-16T00:14:54.826Z" }, + { url = "https://files.pythonhosted.org/packages/73/36/0093091ebb270fcd1bc1f6e095f93b2e0ed7f1011c28837dc2dbe5f96b99/propcache-0.5.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbab5f5ff6897c81f355d079010cdae85b02e5a0b518b5251523b8ad8ae9ac3c", size = 233595, upload-time = "2026-09-16T00:14:56.09Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/0de9d4c8e05ce0be71b436919a216bd7fc5cc6e2691c0602295efb22b9ed/propcache-0.5.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e98c55bde2bcf7db3c70d1aed7ae9aa8aebbf19a250c66645cde44cdb8b867", size = 240318, upload-time = "2026-09-16T00:14:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/2b35e91455209b85ee98f7859583e0814fab57d3af0f2381aaee34c37304/propcache-0.5.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db3ae52ccc150dbc84704e9d642743897f3e1c54742ff34cacb661e52e3818a9", size = 246649, upload-time = "2026-09-16T00:14:59.352Z" }, + { url = "https://files.pythonhosted.org/packages/ed/74/08e6c1faf26ee2732023a3828787ba535557122774f4a386b1f715cbd8e0/propcache-0.5.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f85915e00dcb1cd9f2f890ead064ed40a27df06f0db65be427b29482ae357572", size = 234316, upload-time = "2026-09-16T00:15:00.696Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/08385733c9321c9bb78039d3ff31045e4fca962d9665023c4eb70f998819/propcache-0.5.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2ba30a89035b57b73e00475de948521602f543d79ce01db10b04b36c4c76fc8", size = 204666, upload-time = "2026-09-16T00:15:02.019Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f4/e87bc7629af9a14a752b218764a78742d73c2c563ac58315da6841f0cbe4/propcache-0.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae58f361bd5dae942717c65d3413b478c70aea9c462599e7b9adad3731db3894", size = 225900, upload-time = "2026-09-16T00:15:03.394Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6d/11014938d3fe9bea2ea2dcf930f26ed565bfb2f5be3c756362ea48c92636/propcache-0.5.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:96f7c5c15656040ddcbc51e56dc59b58aa25999d743c126abd425b9766ab43e9", size = 219988, upload-time = "2026-09-16T00:15:04.811Z" }, + { url = "https://files.pythonhosted.org/packages/dc/72/fbf17c589f92c0b3bbf6709a425661f8ef2ed0d46b38985a7d7b5a0f6b91/propcache-0.5.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7cc528e760a8af06f2b13e9b9f362cd90c7c718ea61228a96dbd31ba16ed7f47", size = 233611, upload-time = "2026-09-16T00:15:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/55/7e/dbd637572a279692e5518d117274a9331bf5faac59f191d30e82521a3ec7/propcache-0.5.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:425f8cc86ab5018b4b8d4a23bc8e74d964bd3d757c3702e301aa79be76c53f6c", size = 204333, upload-time = "2026-09-16T00:15:07.961Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5a/f99c92068f1e0f5c886899ce0e4a619db376ca98c5279d93f95bd86906af/propcache-0.5.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5793c7698a53f56f4a1889a4737c7eeb1b7ad0842fa6b1abca22913ff79c8c1", size = 235177, upload-time = "2026-09-16T00:15:09.334Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/95456fabd2daf6be89049a13fbf03341756014d2959c83d12957d4c49694/propcache-0.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c02c0e570c5c7e077b0181a9f3cdb7d4c3617d1cda6b5c95bd5d34022923d82c", size = 228982, upload-time = "2026-09-16T00:15:10.729Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bb/df90f62c9cf7c93ea235f6f9405143bba802914607317266dd81fc8d737e/propcache-0.5.4-cp313-cp313-win32.whl", hash = "sha256:3e413d7a4a9b4866b7a761d6060d434b64d23cd35122eda3b026a0bbe8196b25", size = 42611, upload-time = "2026-09-16T00:15:12.111Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/e0a7b84af04ec02d73a48aa71f091e1e4a2107e3074b7ce12195b66901f4/propcache-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:0c889f6fa84957bc7e8b4eab71fd16a0455068d5045e3aa40c733071d2b2fd77", size = 45342, upload-time = "2026-09-16T00:15:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/9a/70/50b031cafe72a5c1878b903ee87303f71313345566bf3d6ec202e5ddc9ec/propcache-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:69fc35c0779522da366c563e5faf203ffc1f8ff0021d5b1337fa4efa5be73177", size = 42408, upload-time = "2026-09-16T00:15:14.788Z" }, + { url = "https://files.pythonhosted.org/packages/33/c9/07e227b930c8ae513b8ef1aae3793499be097bffcdf7aee4fb8b33db4cd1/propcache-0.5.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e6720ba44ad7e72174314d0e1fb0172494cff5c73a3a8a2159c3d2402ff15565", size = 85933, upload-time = "2026-09-16T00:15:16.073Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e1/6710bb44510c4e4a8e0f004bbaf3cecfd048141309c77bae56d4e5a6ebc1/propcache-0.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cfe0a92ae30151869e67a4b5f5e105e4e03ad30b3f38e5211b5bf77d0881993", size = 50179, upload-time = "2026-09-16T00:15:17.377Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/b533b493d7025456f44518b33e53e000021a20fe7c27b88cf3d341df7186/propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d759d05634f1b038fb625a66662a8c85e5a8fec912da381b5149ddac107482b", size = 51942, upload-time = "2026-09-16T00:15:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/f1/74/70ac8430e28f21e442c7bcb964eb46c4363f6881ade4aa0e978bfd8d503a/propcache-0.5.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:251c63dd46a0659bb875cb254dc4c1e79ee91a847c737cd62373295afc2235dc", size = 232647, upload-time = "2026-09-16T00:15:19.905Z" }, + { url = "https://files.pythonhosted.org/packages/72/95/f222f13b6fe623310be0eb61a673bf26df439ce27e563ca8e422d0818777/propcache-0.5.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a8d5ff04eb1f85698a78d20c62a14676e7b960dcafde09a388d60ad377d355d", size = 241541, upload-time = "2026-09-16T00:15:21.3Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3e/763e370340db16115c5e63ad46e21ef0770a7f06928b3d3b62d8f8edfca4/propcache-0.5.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b9100a93b372418d8688f3f2a3e5b45c64d70ca4d6176e121aca1e3bfc1e32f", size = 245332, upload-time = "2026-09-16T00:15:22.802Z" }, + { url = "https://files.pythonhosted.org/packages/96/d3/e97cd6f5de2176bd90ed4076c7a9b5e09d0f0b9687d00a576507988bb62c/propcache-0.5.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc07876cfb079b6f6f36d21ce75784ad6c2c6b563eeac0ed26c2fa2669b85df9", size = 232757, upload-time = "2026-09-16T00:15:24.374Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4c/6766e5f60bcda26d244333aa71d0a702c1c9b21b251d543c7af5953d1eee/propcache-0.5.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0951315a6b3142ee2167404d707743f0157c110091342b1aa0accac5cf0e4acf", size = 204389, upload-time = "2026-09-16T00:15:25.667Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5e/ec4bb09a70b26ea99d76a8292c3383b960b296de2b347ac9986678f1761c/propcache-0.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bee7d3aed13d56f54e681df38c3a23031bc9e3863f687d9d598825c9146acd7d", size = 228217, upload-time = "2026-09-16T00:15:27.11Z" }, + { url = "https://files.pythonhosted.org/packages/e1/7d/b53922ba7d9e5bf797324e63aa05906ec240871899f779628df068743e2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4e985382be6d15da8d0c2710a6fa7b9070fc9ecdeefb7f580e88373984ec8be3", size = 216947, upload-time = "2026-09-16T00:15:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/ff/39/b62eee45e5ea4de094a258cbb3b01c1e856ca51ddfd95b43135c5effd1eb/propcache-0.5.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e9ab13760aa8b6d0881ae7cb04fd891d8d490cd2554ea8e79bb278399169bcc", size = 233457, upload-time = "2026-09-16T00:15:29.977Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a9/feec61ed296d993db9dd097e0f6723e3f576a647722367547495e4c5b05c/propcache-0.5.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1b2f3bec4261a94019575481c726c29850f72e27907773c75b1de421e20e9f9d", size = 204131, upload-time = "2026-09-16T00:15:31.74Z" }, + { url = "https://files.pythonhosted.org/packages/92/4d/411ef380cddad28dc001f1c6d75ec72c76cd3817030f68ec1ccfba0ec6c1/propcache-0.5.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:720cf832eb2d0b0dfee129cb3335a26f6ce3cc45ee1187e8f0731758caa16792", size = 234820, upload-time = "2026-09-16T00:15:33.087Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/c988229753629ef1cfd5198337a83e624780ea2b3787efe9e747c05aad2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb0a5be8d9aa213150e8d8148a42aca4984b285bcad1e69587dc4298edd929b", size = 228350, upload-time = "2026-09-16T00:15:34.533Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/5ef1c5cf98591da3c5b952b39e6a298084cc1ce353bc70f85e82397a5036/propcache-0.5.4-cp314-cp314-win32.whl", hash = "sha256:30cc1cebaf9aef49db06357a50398323ae04d70460c0491837d026ab7d6452ea", size = 43578, upload-time = "2026-09-16T00:15:35.957Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9e/a0ac821a2229186af5e2e3c3635a78abb23cfddca57f38513ab5d70420f3/propcache-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a095db8e15a6020db149ecbed6461939fe74f6acaa3ae8b702a1fe8c38cd983", size = 46304, upload-time = "2026-09-16T00:15:37.655Z" }, + { url = "https://files.pythonhosted.org/packages/a1/19/c8d0d36a9d16cba5dcee67d389c9333b988c8986a653a61c00a451817a46/propcache-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:45488d1a5f9ab5bd90aaa1ca20f50fe1922b8ffad71a2009d2adf41355897aac", size = 43440, upload-time = "2026-09-16T00:15:39.091Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e9/42f1da77cacfc184e6ec929557ef653b7961bbf6f1da460b9221273948b3/propcache-0.5.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:53eaa697c4d0422ff4cb714d00231b43352064d97b944033b30c1d57cc506ec0", size = 90672, upload-time = "2026-09-16T00:15:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2f/4b79940908c6ab8c795097c102999d7bc1f7e0b8604dfd1c232f9d99d67a/propcache-0.5.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:886b59c4d28ca97dd23b025fdfc50a0356be934efbbbca89ad26230067f86fe5", size = 52586, upload-time = "2026-09-16T00:15:41.575Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/02196ae6320c110235bb343f90dbd34be41f8b8964a3ee30db84ec12579e/propcache-0.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fa15757fea1dfcd5b7745cad9f4638929605531bd4018ab2adff7955f1a403d", size = 54335, upload-time = "2026-09-16T00:15:43.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/44/f48b9a131985659924df5fa5093f68fe72c7ee375329802989ba3126efc6/propcache-0.5.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f0093ac3e9daada202c2082439d414a625c57184727a46e112a3fb2a81cb788", size = 297567, upload-time = "2026-09-16T00:15:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/418d956d2735139f77fc35262179f1f52c23aa666de5a8ab3819c1ae7854/propcache-0.5.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3cd3a7edb6b95b9b33998135ebfa18d709da82290fb8f27c858970b5a12c8b56", size = 297477, upload-time = "2026-09-16T00:15:46.048Z" }, + { url = "https://files.pythonhosted.org/packages/69/fd/ff811fdb6d3d3e67fd9bbfb75881675d34a42d0ef29a45d33e3e233dde07/propcache-0.5.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c174bfd1c48a1b51a3078e95586dde718374bac79719ab3541ec9e74aec40574", size = 302669, upload-time = "2026-09-16T00:15:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/fc/57/527910c455b5ec62f6871bef45d4f79fea16cb8c966ba0d4a07f0339ddc4/propcache-0.5.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a219f0ac59817a9114dd2aa57c13180f993e819ba658c7ddab4b66ed1ee0d370", size = 287908, upload-time = "2026-09-16T00:15:48.99Z" }, + { url = "https://files.pythonhosted.org/packages/1d/86/f69ab82707534a0cb2057bdca04f9200a71214c7551800f9d34d6ac39e4f/propcache-0.5.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17a7400cec0256f0a71ae71f9da398f9894c956ff6668a1c9d317b3367316320", size = 249804, upload-time = "2026-09-16T00:15:50.486Z" }, + { url = "https://files.pythonhosted.org/packages/27/19/60677af50d93be4256213de7cd487f056944c048b9c0b6f2e45b3a30f666/propcache-0.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:978f28401afbc76cdc3df9e1717b4229a06b626a1dcc75db4e1f2beb3884c3e9", size = 282344, upload-time = "2026-09-16T00:15:52.029Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f7/a0057808a91fb3b6a5f3602b528f0cdcb3d53e0ff8315d73fabdfdf8fec4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4a1f4f5ffa55dce6307631f3cb2948e117e665966ea512e0d502b16c24f567e7", size = 270167, upload-time = "2026-09-16T00:15:53.466Z" }, + { url = "https://files.pythonhosted.org/packages/83/c8/f4a865490df0dc0c8531d4e59ac411cb6dc24bb255d2396a6f1c60a368f4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:213bb68d9ced5cf2bf717b1071bf2b09b4b04c426256f9fe6d054c60318424c4", size = 286551, upload-time = "2026-09-16T00:15:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/b0/67/b4faebde9da4e8173d0e5a30e8cd31335914af7ef350b988f27fec588cfd/propcache-0.5.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:286867fb156488c251a3721766e380ac4495e4fd6b51aaa1403d89ce7f4359d9", size = 249595, upload-time = "2026-09-16T00:15:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/52e1dd5636e9f5a27f6b5a4b4e2f33c322fd72afe956c397d82523ec4a80/propcache-0.5.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:445ee3bfb46e85838387fb3c536a73cc0b994dc192b004e40e170adc54aa2a7e", size = 286700, upload-time = "2026-09-16T00:15:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0e/30b2b324b93ff31a0bab539c102aae59e84e444031b2742150a7646aa1bb/propcache-0.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48cb48c5346a97de792254af77715aa2529c2a1ebc5f586aa0aae44a02f1fe57", size = 280500, upload-time = "2026-09-16T00:15:59.487Z" }, + { url = "https://files.pythonhosted.org/packages/64/36/721bb59f682ff060d0c8df64274fca8cd0521b1a54506c2eedaef795b7f5/propcache-0.5.4-cp314-cp314t-win32.whl", hash = "sha256:03b229037d25b801e7af53fd52b9fc49d9439b036fca1e087e02780631adfa97", size = 46121, upload-time = "2026-09-16T00:16:01.349Z" }, + { url = "https://files.pythonhosted.org/packages/c1/86/0b1b80fa1ac3a0aac44e2922a6964fbe9cd52af5eab8fa933bf9e90b030c/propcache-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1fc236528c457cd739c88abe823da851b7ab645d72792f88658114cc340c12", size = 49154, upload-time = "2026-09-16T00:16:02.901Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/9fe6f05a47cb550c823155052116f710064b6be5c6e8ec4e9faae7e18115/propcache-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:135036c5cfc93864affb0f9af9a27e5d7a71cb7bd745e7b6dbfc2d56cc30e827", size = 46005, upload-time = "2026-09-16T00:16:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/58/25/895a11d1e4c5c2acc6d816e2bece34e02d9dc92f2182ae276cd819e9e804/propcache-0.5.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:45bf2e730ab8905d0527fe05a86500f406e64305c34cc81ebe64b4617cab9760", size = 85634, upload-time = "2026-09-16T00:16:05.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/41/c0acd69271de7a1cf439e77d5d60c18575fd09bad56e798b95fa23458ea4/propcache-0.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:31eb43ba2edc704ab2ec27815315dd8a19def0fb16215be4cfe8d32fe78ffd51", size = 50084, upload-time = "2026-09-16T00:16:07.384Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1a/ad561f99f90884089e6403b76c220610809429ba868a81a2e7ce115d32e0/propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:174507f82d3594622acb1dd2dafecf2d899d6d506335494e7107767bf05f3aae", size = 51692, upload-time = "2026-09-16T00:16:08.956Z" }, + { url = "https://files.pythonhosted.org/packages/e9/07/057bdd3a9609ffad59b06239cceee784b047f6c720247bfaa36d2103e138/propcache-0.5.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e337653721d20ead710da33bf44487fbe8a0db8782714b60306481e9f95b51", size = 232947, upload-time = "2026-09-16T00:16:10.466Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dd/d36ad35986718530498a65e45e3713f9f0e6a580f192ef02d2ef7cae9b52/propcache-0.5.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d21d0d2c82bbfeb1677a9711f38df968f9837576102bb4add1bd449d28d88f1", size = 241250, upload-time = "2026-09-16T00:16:12.056Z" }, + { url = "https://files.pythonhosted.org/packages/fb/81/f1459415cdb6c10d46942779de39bb59a77b38e5a76bb1def9227962eb45/propcache-0.5.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccf4f7a79e26bb7efb06ecd50c177833b71df05cbc748701372325e6bcc17f6f", size = 245150, upload-time = "2026-09-16T00:16:13.596Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4e/58b9b1460afc97a4c0b17ee89af701c4011d4d7f46470eba3aaff76a8069/propcache-0.5.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23278f808cd81d5ada7184a76606b925fb3389c60e1077b2cd7da7b1fcf0553c", size = 232166, upload-time = "2026-09-16T00:16:15.126Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c6/5a79e0eda3e7b6987d03d8c622ff6d52a42165a12e8418eb37694b9cc4b4/propcache-0.5.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e738ab81179510ce79b2eac9a6ecf47feffd9e76d1c72e403005dddb6e36c06c", size = 206085, upload-time = "2026-09-16T00:16:16.713Z" }, + { url = "https://files.pythonhosted.org/packages/4e/72/940aed42c73f9da345ca2de0f6e835c726498159abca5f1ef14fb0a2af8a/propcache-0.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a419ee85e654927baabda3929c03c0cc1112bf472ff0dfd6142f4e3a81ca4162", size = 228460, upload-time = "2026-09-16T00:16:18.352Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/3f54e1535c8f323d91ba566044d7c2b39ff6f6a2f1d0bd9071779d07b9b3/propcache-0.5.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b61805357d966680acf68b3b6d49772631ed9df44ebece10ff1460e117a7da8a", size = 218350, upload-time = "2026-09-16T00:16:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/025890cc389ac3ec485ecec607d4a7ca47e15bfa2a465746ab98af602536/propcache-0.5.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:58134228927cee6c047d626c08e60a81be604a20578a12ce752cc5c9a84d4826", size = 233156, upload-time = "2026-09-16T00:16:21.624Z" }, + { url = "https://files.pythonhosted.org/packages/04/29/b39cae08c87c140d3d274f0a2c058cb5588e836175c3309e260b230ab07d/propcache-0.5.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:350b272b2279f4135a64fc0c304a5d08e28a137c9573442c606152446638a831", size = 206206, upload-time = "2026-09-16T00:16:23.204Z" }, + { url = "https://files.pythonhosted.org/packages/18/61/e16462ef18a87247dc9ebbd5c606f46d5ce67e708bd9cc734dd0d9222564/propcache-0.5.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:45bebbe252550fec975ba3b62bc6f931643cfd3b5464ef47619cf3fef154e01c", size = 234469, upload-time = "2026-09-16T00:16:24.841Z" }, + { url = "https://files.pythonhosted.org/packages/9f/84/b6a1490922427204fc47df920ed002eec709621de6b79b11592bf45c623a/propcache-0.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ada748108a43d29b7c328ba7db3755327cd94f028bcc1a7ee3f0addcfacd9c38", size = 227311, upload-time = "2026-09-16T00:16:26.549Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5c/5a59527582e9bcb694b2f08b9894134b65a0f5f79dbff174f054f5f74ed0/propcache-0.5.4-cp315-cp315-win32.whl", hash = "sha256:ee19113bce2f3acd46432050688b70f61acd6857d75abb9ec96341b7e9ced123", size = 43512, upload-time = "2026-09-16T00:16:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/07/93cf699ed363681e754d7c3fad587fb09ef6b65618ee193332ad16a68d7b/propcache-0.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ceb3e879afac028f93d272c957814695dc5569e4904262dbee92f6c41bd5e4a3", size = 46264, upload-time = "2026-09-16T00:16:29.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/fef04fbdcd44a4a163cb5ff5674599c6d6fdefd64a5a459438f9ad2ba042/propcache-0.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:c83acbce9f2b5e3f5f5eda9e53d2001fed22fcdfef81274a9e02d8fd53b70a30", size = 43395, upload-time = "2026-09-16T00:16:31.5Z" }, + { url = "https://files.pythonhosted.org/packages/70/f6/7e2f4dab0b92ab46111bd48cee9ee1e5f519514c44e3779ede5358d7ada0/propcache-0.5.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:a5e8ef588c109725dc713ba69aadcac00a1ef90c2ce9c0a8c7075128f569f47f", size = 89825, upload-time = "2026-09-16T00:16:43.115Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8b/dfeff925cb6ced97ede701d5c6a99998da963c6f2e06abbf879c9dac5b54/propcache-0.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4d86476a935c88963d9b8e1a9a0d38188790e9622169bfbafa173046846709d3", size = 52159, upload-time = "2026-09-16T00:16:44.754Z" }, + { url = "https://files.pythonhosted.org/packages/24/6c/924c810be5b7cf218ef47e707cf06d34adb4e3f3a31e3c24c55c6d945a88/propcache-0.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5470694918830da62fac9e69133b53d23b736d7070e587b27a4a2be37e08e68", size = 53956, upload-time = "2026-09-16T00:16:46.762Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b6/9ed0a5c939b58b6bed740a05b5d0f919f0b318d03284b4b6d81a0fe8a29a/propcache-0.5.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10ef33a68a61ce317e095fd2e202a592ea92392b90944a78c993f0d9a73ab06c", size = 295235, upload-time = "2026-09-16T00:16:48.577Z" }, + { url = "https://files.pythonhosted.org/packages/5a/eb/5ce886e902a2e781dddf110993d5329458a9b1a8626b876c65e5e25bf413/propcache-0.5.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cacf3c9efd09df409dc33654dd077e1c245ba8fb747b0f0236ef41b7c49b589", size = 294463, upload-time = "2026-09-16T00:16:50.539Z" }, + { url = "https://files.pythonhosted.org/packages/f2/88/c98f49183ecd3e5b204a556f0ca47baa02c2206a500fe8c7ec1726297b0a/propcache-0.5.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:770e8209d018175fc0063936fa9583b6d27e88c5ad31543f3383d66080efdd62", size = 300081, upload-time = "2026-09-16T00:16:52.423Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/c5090f9e6f67cbc30a2b744c7bb0f8006dcba5ec1b0d82f866ae1cc7c5c4/propcache-0.5.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03969626faf0783a592dfa17e28eac06018bd0b44dafae6943d53b92421a7f72", size = 285360, upload-time = "2026-09-16T00:16:54.141Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9c/34a55396910583ed07926669ab309dde2213a2dec05a7e946bb90ad66908/propcache-0.5.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef3b928d9c984322b5c44e6964d8dbc653da87d2d8ee1647fa6da43072e650a9", size = 248014, upload-time = "2026-09-16T00:16:56.062Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b5/c0a142b656093ca397039dd3fe166cbb87c945712b534546514a24cd2611/propcache-0.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7177c43eddf10a0893c4fec52ebb408fdcd7f7d63962caace9180d8f81b14ece", size = 280662, upload-time = "2026-09-16T00:16:58.044Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/fab2809c2e337fe26becea9648e84d5cef46075c91b826acb13e4f9dd04e/propcache-0.5.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:420162a77f94eb1cf5ef7893f500016dabd548e73de956785a1dd899cc73006a", size = 266149, upload-time = "2026-09-16T00:16:59.702Z" }, + { url = "https://files.pythonhosted.org/packages/3a/11/7ddf336288b2678a5f054f8da2e2bd1a719f5d4b7de714d9c6bd588a2313/propcache-0.5.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3eb2e820e8e2101407da93f17c57cbb7d225461955fc60105daaba14cd421ee2", size = 283097, upload-time = "2026-09-16T00:17:01.459Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ae/351b1a5225f5473c411d9a612a229ae147cf0cf65c72ad838b87219ea8e8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:13e52b6e0bde97dee98ab66552dbff2931649c96f1ac432eac299fe689ec373b", size = 248160, upload-time = "2026-09-16T00:17:03.298Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d0/7f79f061e30d135bb615c9782c94a74652033d00b49254edbbf35a9165a8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12682126712ddc19b70ff819debbd279e58adf1f0c8f8f8138c18ade2044b284", size = 283036, upload-time = "2026-09-16T00:17:05.238Z" }, + { url = "https://files.pythonhosted.org/packages/53/3c/016f1cad8bf4c428d748cf399b2bac603026fbfd6966e6a5579b5c5b6956/propcache-0.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3af0c8642b2da4815d86e631232ac8286e17644fad907c19508aa8e7cb4ba8ad", size = 279350, upload-time = "2026-09-16T00:17:06.881Z" }, + { url = "https://files.pythonhosted.org/packages/ea/60/d8f72cb24b412487ed4c397f539117d3b74c3c33dd32020e91fe00a958a8/propcache-0.5.4-cp315-cp315t-win32.whl", hash = "sha256:1df8d8561b21465c5dd56110a01caf897e026d065b4b84e98a488209094272ec", size = 45874, upload-time = "2026-09-16T00:17:08.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ef/8bae0a316d406644450522f2f3d44a4e19632f5f3bb60d1d0e6c53842616/propcache-0.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:02c0a34f16889cf800f10f0247a564d8ce6eeab6ffcd7c87198f769067eb8432", size = 48574, upload-time = "2026-09-16T00:17:10.077Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/bcc053f66a97355683884b448198e79580fae8e8fa4d96b9bb01614e9913/propcache-0.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:dc4242ca653c9b30ab51c5f8193323e7bc0928f897ee9103201e59a43abcb72e", size = 45625, upload-time = "2026-09-16T00:17:11.377Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" }, +] + +[[package]] +name = "pwdlib" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/a6/644f28a21cb68920d06b264e72b864ac9bff2377f4757dd4648b5110b3ad/pwdlib-0.3.1.tar.gz", hash = "sha256:2ffd1955c92190d5bfbdc5071bf7d422568b83f943453ebd3d43d031ccba37b1", size = 216951, upload-time = "2026-08-12T08:38:38.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/2e/51979293b2337f508597d13ee20db1ac1d43e48086b2ed7de6da213ed46b/pwdlib-0.3.1-py3-none-any.whl", hash = "sha256:944fa439198bed8ac3ea63dac76ea4ae43e2a35f4f21e9e725e82d3c2b97d6a5", size = 9272, upload-time = "2026-08-12T08:38:37.483Z" }, +] + +[package.optional-dependencies] +argon2 = [ + { name = "argon2-cffi" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +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/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[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/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 = "ruff" +version = "0.16.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, + { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, + { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.53.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/ad/04bbb797c84fc1f26cb171f7394716f4865ffb8d8c5e1eef42565c2dfa6b/uvicorn-0.53.0.tar.gz", hash = "sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e", size = 110881, upload-time = "2026-09-14T07:44:23.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/18/0eea75741ee812e9f598b687619ce2454f6c3a1c5cd21ea990ec6bd26f45/uvicorn-0.53.0-py3-none-any.whl", hash = "sha256:e8dca71ec86dce5f04e333f0d56cdedf942446e6643b9cea1af0d6d3a02cb03e", size = 87081, upload-time = "2026-09-14T07:44:22.179Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "websockets" +version = "17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/31/5f6450a7879f4f063ef08897cc385ea3ce3f1fe17f08b11e3fd959abdf27/websockets-17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a0162a6372110a5601cb5c9fd826635cedf69f3e110c545dd19774e040b970e", size = 217006, upload-time = "2026-08-26T14:56:10.509Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2a/c1b006fc861695d2aa4e35327b842015ce1d98cf8f99241829b3d6460bfc/websockets-17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:829dba1bc049779de9b332088c1a6a9858e96bd67e50b6b644a95e02b67836bc", size = 214690, upload-time = "2026-08-26T14:56:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/66e5b7d01445e0eeb1d4ab419c30315f2c90cf7a8a8cd4ecc47f894dba54/websockets-17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd8f47dbf2e8adb15c847215f83436de3fdb120b51fdae0fbbdf69fd97a3ad80", size = 214947, upload-time = "2026-08-26T14:56:12.923Z" }, + { url = "https://files.pythonhosted.org/packages/07/ce/033cafe2d2538562efa876b9149a2c7a0f7787870a4b1bb6e28adc9ceb6b/websockets-17.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f4c0377a83e163a303514fdfab501dbe379bdc13e5b9312a91d112658b29dce", size = 224329, upload-time = "2026-08-26T14:56:14.212Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/e1c2e8a67f6cc0aa43abe0046fb3b7a020980649e6a843751dc7ce9eb170/websockets-17.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c3241d684a76eaaef8b2dc789afde4343cd3aad55ea81e4e8ab3605b529bae51", size = 224611, upload-time = "2026-08-26T14:56:15.702Z" }, + { url = "https://files.pythonhosted.org/packages/be/de/07c6d48eb3d2069709410c851e7de10ab83d752c4bd09862899627c2729b/websockets-17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5f5c7a893507d0e83a80b88aefd6522f7e882cd53f9722c6f23f5a020c9557c", size = 225848, upload-time = "2026-08-26T14:56:16.962Z" }, + { url = "https://files.pythonhosted.org/packages/f3/dd/3c68572d20509648cc2fb6f50ccf3deeb4b87270f2c8966e99476e278ea3/websockets-17.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00bf34b64501e3477e81fc281532ff3cbf4da26633c10b63979d5085d46602d3", size = 227290, upload-time = "2026-08-26T14:56:18.204Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4a/8f6651c8a22093539c9215af0c5bbf217b87b382c99d2112039b92d593c2/websockets-17.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ce0305b702b20d1e1d60a9aaace6bc89970e1753565543f310d549eab22c2435", size = 226476, upload-time = "2026-08-26T14:56:19.459Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/f6fc33cea86b1127fd1297b18c107e81580ab55a73a39f9a934441ef321f/websockets-17.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29176d8b429cfa0fa443c473878d37a5c06cfd0cb36b71ba4314accc71e05906", size = 225233, upload-time = "2026-08-26T14:56:20.939Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/65edaf05f7c9b1dea82f4d252fdc37706a84571646f06119a27b0a16fe19/websockets-17.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3709a1ab30b4b922027d22f68d2b61a0656a91680ac894a537624e6be7dd7f7c", size = 222488, upload-time = "2026-08-26T14:56:22.208Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/d1169c2f7f1f0032b0d4b0c00f0711a070cd7c735de37bfeb876bc0f9606/websockets-17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43bd0c1ceb924d67f5c1a5254d8361dd9d94246e6331a726064dfa2917880780", size = 225295, upload-time = "2026-08-26T14:56:23.445Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f4/64e2a386c3899b917c2933225c9b47887874229d159797f3bf1a11c20d51/websockets-17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:1fce0f43e0d41422e0b2cad6561e1970df22f212f4c7e884967df7cf591b031c", size = 223891, upload-time = "2026-08-26T14:56:24.647Z" }, + { url = "https://files.pythonhosted.org/packages/26/b3/dfb5c482f7e310a3432fdbb045ddfe6d34114680e89a233d4ff900a32961/websockets-17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4031152769179ab8dcdeafc7b0e58052a49117560a28671700b47b2c7b717aad", size = 224661, upload-time = "2026-08-26T14:56:26.027Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cf/94865130a336029f46412adc127c4fbe380f46172b90ce251369e35c4302/websockets-17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a06f3b5085176763182449559e20391d7ce616a8972a9f7a33deda87ea6d4f3c", size = 225766, upload-time = "2026-08-26T14:56:27.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/eb8c658f86dfe562ed49a887a27424bfe9e618c26ea6f865b093d075d3a6/websockets-17.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:77b37cceca17291897c3c73bd30a7c7c7909593554b5da574ec852af83c1742a", size = 223323, upload-time = "2026-08-26T14:56:28.807Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/2629609652ece5ca0c7ac235927dd4511b08131e3a5d53439b798fddf002/websockets-17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d8e83333385cac6030a5167fd18bf96cc6c58b914c308e683f05b0cf94bc8dd0", size = 224276, upload-time = "2026-08-26T14:56:29.991Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/8525737fe840b38e5f40956c198fb586a4fac1e07144d41a5b949b989cf8/websockets-17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:073c5c3f7e127041fa9d34a9e29ceefee8c3cafbd267ed2927318f425144380d", size = 224558, upload-time = "2026-08-26T14:56:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/3a958c6cbcf74b118f601c20a80ac8bd5e8dfec0bcf7345116feaeefb121/websockets-17.1-cp313-cp313-win32.whl", hash = "sha256:2afb58c7ba48b329d56769f8dfd89f394efe587b65ef806bae810a484d6d3608", size = 217475, upload-time = "2026-08-26T14:56:32.431Z" }, + { url = "https://files.pythonhosted.org/packages/22/36/fb521f0f2994c25509651f169efe5582dddd8713d57a0757ba87859372ef/websockets-17.1-cp313-cp313-win_amd64.whl", hash = "sha256:0340bbef6bfbe16da888b3983d666a4db4954ac3253c38f13bc7aba0c7db5a2f", size = 217784, upload-time = "2026-08-26T14:56:33.608Z" }, + { url = "https://files.pythonhosted.org/packages/68/92/9b8419584681a12a7534b746dfb2737c466efe2455483e2fbf8b941a04ec/websockets-17.1-cp313-cp313-win_arm64.whl", hash = "sha256:7a72efa3bf4fa3a6669a54420a472ad056da3973d827f10e3a536da463f926c2", size = 217715, upload-time = "2026-08-26T14:56:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/90/0d/500cf5daea09d4669dff3a7d67159094a0bd6c4ef130381404f6edd3eb5f/websockets-17.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0c9982938980e086da59f70d05f9418cd143401a601a0faac10fa48f7bb1cd3e", size = 217048, upload-time = "2026-08-26T14:56:36.03Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/5b12c6168aa269cffbfd24d177cd492b130120403a418c7e89462e27b4ac/websockets-17.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:57b39dc8541cf7ed3f639da82bf7451060483967f9e733da1f8173e4095f0642", size = 214737, upload-time = "2026-08-26T14:56:37.43Z" }, + { url = "https://files.pythonhosted.org/packages/0c/36/e453e5106e4e2416f008ac222837c2f1637a063b08008afcd1088889b631/websockets-17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:96abdecbaae746851b87c3a36cb4a661df93ca3d92f114270f79228bf1d00de6", size = 214955, upload-time = "2026-08-26T14:56:38.71Z" }, + { url = "https://files.pythonhosted.org/packages/dd/30/0204bb86176db02cdfc678ce65ed808a66fab87d250ce61a8790800a60b0/websockets-17.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9fc873e239c5abeb150bc24dbd1a7af23a9254526383ce0a077f5e20adbeb19", size = 224331, upload-time = "2026-08-26T14:56:39.924Z" }, + { url = "https://files.pythonhosted.org/packages/46/c8/d8372256e00c4e3cab1115c45075d1eeedb642a3f2b42bd70c4deae03f06/websockets-17.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f42912fa9eb4cb7c7ec9fde9b3332ba339eb8a8811981043d4029599f3d950b", size = 224685, upload-time = "2026-08-26T14:56:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/12/7d/650355b8f67f908ff99603351d4458d1a0b787d627950a47c38db7e25308/websockets-17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f98bf378d7a5be047a044a1a27c987a8f355e10e3b5754617dbe756248cbc5ce", size = 225927, upload-time = "2026-08-26T14:56:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/34/6c/a9ffa5b903579eed76017870f055d75ecc73988d9d0c9b65a92ba0bf2a27/websockets-17.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d334d11398086bb5559606cb42d51c013ea7c061c7db701521392373d3c087f5", size = 227300, upload-time = "2026-08-26T14:56:43.538Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/4551c2269066af7481ee44605a0813770961615b5b5da3e87a8f5cb859ea/websockets-17.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c27336b1a0ac56569493e858497870347854372395f50483725f8cdacc5a45c", size = 226533, upload-time = "2026-08-26T14:56:44.669Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/237a99233e5c445759a613831b3a92e91905afc064dc3bd0ad33c35fd1e2/websockets-17.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67258b00302a5aaf0b267771c7014b13429abd7ea17eebc4c55bd935ff101555", size = 225280, upload-time = "2026-08-26T14:56:45.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b5/e9407a91613d1d1cd932414143a1012096b26674a782fc55a0bd23217ee4/websockets-17.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:455ffeea0879d313205df1e745e5883e1feb7f31ecd26be882f5f0babd3db04f", size = 222540, upload-time = "2026-08-26T14:56:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/db/d2/db76628db0577b783205d9779f64d8e373416b04c62d1546be4b75dc8540/websockets-17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f7233eaf441a345a5943a929fd4b5ea3278f11aed35a9ed0f3106b8cb3ca846a", size = 225354, upload-time = "2026-08-26T14:56:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4c/2174181c067b89a74ae18e2650c2ac29959f4b796afe876ab3f4d30d642c/websockets-17.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c65da239a5ad553619804c1f9d65c1a0b3005381c6158ee14da2c7444cbd0c78", size = 223867, upload-time = "2026-08-26T14:56:49.579Z" }, + { url = "https://files.pythonhosted.org/packages/df/75/274decb9a8253561b5be3261e02a6676fc8ecdf31e95b722e53d5bfb8fd2/websockets-17.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fa1ffa08c81a4f809cdab6129f8e55bee4650b9d6d3461019dda73aacd146b6", size = 224652, upload-time = "2026-08-26T14:56:50.885Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e6/49824f1fb4db7656d2f7492b1d8be16147b759d909490e32f4776843ee64/websockets-17.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:406b8107943a43ef4649b1e0cb0cdc052bbf08fe1c8905a623c4af9586e5cebb", size = 225822, upload-time = "2026-08-26T14:56:52.356Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/5dc43838c0b02a95f42c47a0de33c5ddd7767a9feeb4d0d8777ac1cfefe4/websockets-17.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4e8ffcb486c8490a34a4cef5e4409d8da5a1cb1681e5bf7d786ce5e84aa8540d", size = 223379, upload-time = "2026-08-26T14:56:53.699Z" }, + { url = "https://files.pythonhosted.org/packages/c2/62/585637cf06d6b321232f79c55dc14d65518d12cf87c94c44f5864068810e/websockets-17.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fb88076df585b69c5761c387c0081aa87d7b9eb1b205a6535ca4777e25650d81", size = 224330, upload-time = "2026-08-26T14:56:55.184Z" }, + { url = "https://files.pythonhosted.org/packages/de/68/c3b234a6a1366b6ab5bbfaa4434a1b946e1dc4e8ddd6824bfd93a8835b7f/websockets-17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5d4724255fb8398acd9e583b97eb2279cec20e0bd0f9a94bf75f6056ef9f13da", size = 224622, upload-time = "2026-08-26T14:56:56.393Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d4/84cf3d1376f5d8207f55f43c1c818babd6b89447f5dcd01f18a6d5526796/websockets-17.1-cp314-cp314-win32.whl", hash = "sha256:be3f0129c5654517b2abf07dcb75bb1d9479759a4ccfb569e8293579e9fc029a", size = 217036, upload-time = "2026-08-26T14:56:57.652Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0f/9e7ac63c5d7cb642952200814f584318e65146df008b7d375d5d9c6b2c97/websockets-17.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a4dc6ef83f4559e0d05f313a375cb38f63c986096a9da99fe94fdd779d313e5", size = 217382, upload-time = "2026-08-26T14:56:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/1ae6b91f7f3ac05f5c9f14a72dc2181c115ff370bcd8a7f10f02c174adfd/websockets-17.1-cp314-cp314-win_arm64.whl", hash = "sha256:46c0331c9eaaf73a559f3a9e388466be0df96eb83d40f06f1ca6ab6613b35c82", size = 217268, upload-time = "2026-08-26T14:57:00.654Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f0/f65644d0e0b2b90918a8c41503841cc4072a58f2bf76c09bc36e751fc0dd/websockets-17.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d411ea5ca18ac1b12c0c94be88b60c18ca641ac43bcdfdf1c9f79d46cdbe1603", size = 217379, upload-time = "2026-08-26T14:57:02.181Z" }, + { url = "https://files.pythonhosted.org/packages/ff/35/4c46d1f620ac1a30f92b6eae78ee40a772a93f568647ca7ccdc5ea283cf8/websockets-17.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:07fa3e7c30e2c577928d359b56bf872a3e0cbcc15553eaa0907c1ee86344b56f", size = 214911, upload-time = "2026-08-26T14:57:03.478Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/4587e8406d7c1188e97b9cf466c081e93399380d447f885bfce81626cd37/websockets-17.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6de9acef07e3a78e9567fcd26c29011a4da8f050b13004bbf880a0fd82a6eea5", size = 215115, upload-time = "2026-08-26T14:57:04.692Z" }, + { url = "https://files.pythonhosted.org/packages/ec/06/1381c8fff525041025909eb80ace32489194a00ba22a0a8d428030afcc84/websockets-17.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea0ed9373b880115911d9d39634bccc95b8ce590c9c42e8589f5cacc3ef3cee2", size = 224696, upload-time = "2026-08-26T14:57:05.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/9d/9034e867dc85340be058619751742b895f722326e83100d110063461ca07/websockets-17.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50903d335bfda026c2fa11dd9aed09d8cbee0c451e3a85122a9acb041b7dc69b", size = 224975, upload-time = "2026-08-26T14:57:07.262Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/ed03aa3cae748ebf6397e5d44028f433f746bad09dc568ff754fda3a3c9b/websockets-17.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a74531ce81af587f906ab42f194032388fcff8fc7938402e5917c9147a39441", size = 226151, upload-time = "2026-08-26T14:57:08.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c9/cc1964a096d16f3b73cb1ee5f14f277f5a3bcac07c6e8f9a1dcded99f4c8/websockets-17.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8fbf28e639544503b7d1c96452a5e5e043e4108d89b1f3fa02910603622d19db", size = 228292, upload-time = "2026-08-26T14:57:09.846Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/46da6dd0363c2db2e4876fd59a40fd40c1943a82d7018d0a33afbce47d52/websockets-17.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f612dc57f00c07cf4aa2673f7cbceabd654ad2457b7e639f061b794d6e11f9fd", size = 226722, upload-time = "2026-08-26T14:57:11.118Z" }, + { url = "https://files.pythonhosted.org/packages/78/98/ecd8f5e1c5d0e54c08ebc5c66852271112166db68107cb0e17ca1bf25009/websockets-17.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c7ac77401227212dc6e849182feee50d57cf456ec6329ffd6979c94bb136c5c", size = 225451, upload-time = "2026-08-26T14:57:12.601Z" }, + { url = "https://files.pythonhosted.org/packages/65/4d/da8d2760db53e17aae763738b6ba834b1fcf16813d3632f3edb6951e1ec8/websockets-17.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32a2a68d989d6e5b74a9d5095415c51189ebae29fceb7cf2b64a1c0318a81256", size = 223003, upload-time = "2026-08-26T14:57:13.875Z" }, + { url = "https://files.pythonhosted.org/packages/a4/40/ea401c141a79c5b1d0021a0dab9d0df2051c108f1620fbb39a6e7c714c3b/websockets-17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:aec00f018d34c67500ff0438dc314b40277be4a1b983cbacbf53ccf7db63e257", size = 225704, upload-time = "2026-08-26T14:57:15.091Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/07ab3f44215d89840d5385fdcaaab1fed8caeffa67c6899e15062957c12c/websockets-17.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0014eaff8ad5b3b43feda2279f9d34bf2eaae040720b9fbbb55944b10f40b14d", size = 224192, upload-time = "2026-08-26T14:57:16.3Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/ccf1af0a23e5748d4e22292a377d78d15cf294d7e707bbb11a8990ae6bd5/websockets-17.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:db9d7ee47f3ba531e278be539af39e2c7c7d28fb94897b6cd1120d63b0ef5922", size = 225082, upload-time = "2026-08-26T14:57:17.531Z" }, + { url = "https://files.pythonhosted.org/packages/e2/db/e32200f99ce282e728d2929f2c429db353cf3282db7d0eba99eb32c9fec1/websockets-17.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ff3e2ba7a9f0a110b0555452e9b5a03a34e11662544e01beea15f144b48ba7b7", size = 226101, upload-time = "2026-08-26T14:57:18.802Z" }, + { url = "https://files.pythonhosted.org/packages/28/3d/e7a6e9777b29433620167c98f3caaff0d6b08b1239a273ef7f7fd1393349/websockets-17.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6da17fc94bd270f5987b10bee113461ac36a36a98b0481ddcc98056e5a90001a", size = 223794, upload-time = "2026-08-26T14:57:20.313Z" }, + { url = "https://files.pythonhosted.org/packages/48/05/ac569090726dedd6656f3ee28b0c02dfb1ba76e898dceaccc2987a237cef/websockets-17.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e8dc3fa6d6b7ead3f9de57895f41b116a28787548e066365d9d90f7356bcaad2", size = 224567, upload-time = "2026-08-26T14:57:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/14/50/4ef62941111db6b31193f4fabbb65f845a5177579040cb8fe0d774d25034/websockets-17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b65d5fe48219dc2d5e158de9e6514e75600f379cc7e37108d35f31764c155566", size = 224993, upload-time = "2026-08-26T14:57:22.86Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/2b95ada4ea19bf3a2072b68669ce4f4afb212690b727d31640576287fd68/websockets-17.1-cp314-cp314t-win32.whl", hash = "sha256:2cce251f3e2469b99b6802b55435bcdd07123b41870f54c87b336183af9d7e68", size = 217168, upload-time = "2026-08-26T14:57:24.466Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/67d5ee08dd8060a37d612fd40a625b5376ad19ae48fe1c8ad428c278b817/websockets-17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f6c38cdcaf98a911d7acc25577f2f9e710f3a2fc2bde1563556784320196b51", size = 217508, upload-time = "2026-08-26T14:57:25.983Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/822005d0c674451d2411027b878cdc128a2b7ea5a30d337d9e279da22eba/websockets-17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d1e2f5fa2b6d01f0d85b4f223fea7ed1d504be282a02a81bd2be4817ef7a2f03", size = 217425, upload-time = "2026-08-26T14:57:27.324Z" }, + { url = "https://files.pythonhosted.org/packages/de/d5/99a6c6a1eb5d5ae9f45f59a3c97f4e3b21f310eb404a547fb3e7d2fc054c/websockets-17.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:88381602e379165b66244b2ebc29f9b23ea0851fbe63ae157f91ca324f072d6f", size = 216970, upload-time = "2026-08-26T14:57:28.575Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0e/1e7f6e833728193958d3ed3d67b5d57c3c7cfa948abf94d4bc553257c954/websockets-17.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:88bc5138e53903a85c354e59df7ba73ce306f7b09724cef74dba121e60a88ce2", size = 214699, upload-time = "2026-08-26T14:57:29.862Z" }, + { url = "https://files.pythonhosted.org/packages/07/00/95d39549f86e34425a0412bcbe61708dd1fc46af654e2134a6c4389102ad/websockets-17.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3546ef55b3a074494106508bc6505c73825970d2d9505f7bf53882b3e88b0d1e", size = 214927, upload-time = "2026-08-26T14:57:31.148Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ff/b442415fc4f7f9943b0fc8e8eebaa13923ca73361e167c439ba634eecbd9/websockets-17.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9ae55d24241fc055f22aea3ac924559069848bd0ad4ea065fdd72d2194685fe8", size = 224373, upload-time = "2026-08-26T14:57:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/a8/dd/b83537aae4cf61615b9d8b2dbb235c0030ba85457a6d934798273814600f/websockets-17.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7b349265fad6244013eecd99df8d83c12bf3013943e431f4fadd5bffc37db42", size = 224801, upload-time = "2026-08-26T14:57:34.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/83/5ab0abed58454909e8dbab45086ac68ee4556d7a8ada26735addc909b903/websockets-17.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc5789e5ea182b77a38881383ada5347202a6c66f4857d054e075290e80b604b", size = 225967, upload-time = "2026-08-26T14:57:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/4b/26/e2412f2b998a8c1dfc00c0709ff6ee0c634dd0b0b4f92bdfe9667876b71c/websockets-17.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce13c7d233239e739600a57d4a73c1192ad8259e655a4d55aa1a454242bc809d", size = 227664, upload-time = "2026-08-26T14:57:36.493Z" }, + { url = "https://files.pythonhosted.org/packages/ec/25/0dd4495df3c0e02f6db705312ba85ab9b2dd42257dc23eb0da10066e4844/websockets-17.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1036189bd34b0bc1b10a4679321e2c7968af317efe6e8e4c1c5141c4254fb5bb", size = 226447, upload-time = "2026-08-26T14:57:37.781Z" }, + { url = "https://files.pythonhosted.org/packages/be/67/6df3f63ffc48f08126ed0cd2fd2a41092967c3e364f8ec100deae90b6d77/websockets-17.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e78fd4b7b2c5086a38671c9c882c1e643385eccea360b5b1fda4a105e590087e", size = 225343, upload-time = "2026-08-26T14:57:39.133Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8d/a8479bbb09ff054907d141123d8f52fb6ae5ac39c6dbe39e6a02a8408309/websockets-17.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46e7a10bf04318c7b0c0273791925ae5e1cbe4a11e34aa934d2ef27862058a80", size = 222748, upload-time = "2026-08-26T14:57:40.478Z" }, + { url = "https://files.pythonhosted.org/packages/40/fb/4c3d2a3269cde3f3087916de9c3d9fc5d7196b46846d8c3a9ae59ad0a884/websockets-17.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:33e45c7ea38428e740a7f233555d71df0b875cef7fc080acebc9654475e35335", size = 225453, upload-time = "2026-08-26T14:57:41.859Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1c/6467b401d19408f34e1c7389c222c2c7e1dfdf08c551190269b5eabc726c/websockets-17.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6e63c01803be425ff062b7f7fc201a74def1d49fc94a2410dd17375df75936e9", size = 224112, upload-time = "2026-08-26T14:57:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5f/744e032ac80e11039a7447657ebabb46e9b5c2dbcec83be571335212932f/websockets-17.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:722ec21717eec6477bce582147a28acdfe034e604239466a6a95daedb863e774", size = 224646, upload-time = "2026-08-26T14:57:44.871Z" }, + { url = "https://files.pythonhosted.org/packages/9f/47/bcb9128d9afc4d0934d9192e2a24897ca2f7a63df2654904915349c6c46d/websockets-17.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:e74e41f0ad12ff1e8983e349daef79d37cc8280c743ce9d134d6c74c18dab5d6", size = 225797, upload-time = "2026-08-26T14:57:46.338Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e0/b058047b7cf565e1105b10ef6b6b24a6ebe3575678c7dc75a645334705a7/websockets-17.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:12fe8984a32dbfd084e0603f1a8d740c0180cb85b3174585c54a80d2455a8394", size = 223605, upload-time = "2026-08-26T14:57:48.175Z" }, + { url = "https://files.pythonhosted.org/packages/b9/69/fc1555bff884de363f1bf9eebf2836dbeb29fa7e4f957debb7bbcf43abba/websockets-17.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:01dcb47deebc40b38fd4a493b9b9f4d0a704b7bec6f35e4d34085b329abce71a", size = 224508, upload-time = "2026-08-26T14:57:49.407Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/648d4e68621688b19093b06f7b497d520952e68cdea1c1b54371fe9491de/websockets-17.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f4c45ee2512d3757b5e6c67c5a34e435143f2ecb7df3324f9fd888688c45c0f4", size = 224767, upload-time = "2026-08-26T14:57:50.799Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/f8342b55864f71df13eb8e9ef7dce691b87a87f04f75bb8a1385b3336e7c/websockets-17.1-cp315-cp315-win32.whl", hash = "sha256:0f4f50dfe2cc810fc4e2de979b35e83bf8bb4bccdc6fe472d93762ea7b1d5927", size = 217003, upload-time = "2026-08-26T14:57:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f0/7b5fdb774c245e0b6217009e2a24d2105c1a64923949f33be41aa7959302/websockets-17.1-cp315-cp315-win_amd64.whl", hash = "sha256:4af784f3e436f65b355c117c6497320f2b5cf6a559295cb1c4c7338e335d45cc", size = 217300, upload-time = "2026-08-26T14:57:53.492Z" }, + { url = "https://files.pythonhosted.org/packages/76/33/1fe6ed1b5087516115ca451b2c240314b010647071f8fc3bd78a21e4dddb/websockets-17.1-cp315-cp315-win_arm64.whl", hash = "sha256:d58159af7835fde09c462394293c0d7aaf8fb4557d8f8e5699f5e722ccae013d", size = 217214, upload-time = "2026-08-26T14:57:54.88Z" }, + { url = "https://files.pythonhosted.org/packages/94/ca/ed02e75996a266d76c5fcb5dd9b930db4cf2b388ca5fa3d2a72086f81568/websockets-17.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1a5cf4e7bbe3ca499e6a289206cb4fcb7444b09919e129bd517f57d5fa192c13", size = 217282, upload-time = "2026-08-26T14:57:56.108Z" }, + { url = "https://files.pythonhosted.org/packages/bd/7d/d536f5bc89ea5b52fd1c1727c59fabafee6bc41f5ce92c3bd2f83047908c/websockets-17.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:416b4bc8789a1865a3ff643ec4ee073a5f52402d0dbeafd27b1798d5dd6b6a51", size = 214863, upload-time = "2026-08-26T14:57:57.355Z" }, + { url = "https://files.pythonhosted.org/packages/37/37/944cf17bad668e9be1247e6314f88a48b9faf7c250e383410db8b38af0b9/websockets-17.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:259f45358c76d3b18489e3e80636cdbe807e05ecf1b10fdf1a779106d23d0c8e", size = 215073, upload-time = "2026-08-26T14:57:58.719Z" }, + { url = "https://files.pythonhosted.org/packages/74/bf/3267966cc1bbc2b8fa62fd329651b0af502df1f5d1c0eed027ff339d6aa8/websockets-17.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9d01e8ede41fea4f5a847dad9d628355f74905f437a5b6856d67aa66d193800", size = 225229, upload-time = "2026-08-26T14:58:00.235Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d8/85ea722f483510abb39fc71aafb4465d17cf9051a275ab036874ff3c300c/websockets-17.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7b35181a14cbfcae163b4de545d22abfd07d06c2c41ca69cfcd99251d6888ab", size = 225500, upload-time = "2026-08-26T14:58:01.994Z" }, + { url = "https://files.pythonhosted.org/packages/50/ce/64c7d00005bd0d15ecb5c5fcb7fb2597b6b92ddd16c4fa6bbc3d2835ad63/websockets-17.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a8e768a048c2220697477ce2e67e4345dc9f693d0ee6af53945b5e30227c6a7", size = 226829, upload-time = "2026-08-26T14:58:03.327Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/096c67940fb957e667ca3c542818150434eb0388c6fdc90b3a502f3c3e96/websockets-17.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:880069d21cc33a558dcf180924a546d1ecf8ada5be3e4e70acee87019d706a24", size = 228457, upload-time = "2026-08-26T14:58:04.78Z" }, + { url = "https://files.pythonhosted.org/packages/51/fe/f2331b6b7ccc67589891da354fa46a5cb79e95f83b9fd0e734d77f1f2140/websockets-17.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cec1bb8f22abccc8d20f8ca63df9be41600c26c190f4b97ee86c675fd4a863a6", size = 227265, upload-time = "2026-08-26T14:58:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/47/a5/fb1642302f8ec77ca922203074f155a9831a5128ad75e725059a476d1227/websockets-17.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3a1d577e081667dda7f8e5b4796e6e32f9713c93e2a3d930669519840a3c623", size = 226143, upload-time = "2026-08-26T14:58:07.464Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/7133fcfb63f5562750b269d6a845c689dde6a2c6407286da395beea19ddd/websockets-17.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc053f9e95a76213c5eb7ed95779f7daf0d2bf0e4e03073629ebfa43a033f151", size = 223501, upload-time = "2026-08-26T14:58:08.766Z" }, + { url = "https://files.pythonhosted.org/packages/64/b1/82b36bfabc79ff2d383a1fc043cee6a13f794ef4f6bf1b4810ad6988cf6f/websockets-17.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bb0efe019480a1c93e168ce96479273aaebd672fc8c350d5eed1e507ababb1b8", size = 226330, upload-time = "2026-08-26T14:58:09.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/7d/5b511b9bf6e9ad331e6ff902fcbcc71c3794d10ef3b5efe80ccb8f0a7861/websockets-17.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:615746b12b26a3fd4077bc6fbeb277a1c192a45dd57b531d07ad9ed5c52a9a7a", size = 224980, upload-time = "2026-08-26T14:58:11.303Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/aed08f25301f8eef23be903ff9319fcf35630ca2bdec9d226f7d804dd5b3/websockets-17.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:1a20136d61f9ca3a31493732762661fafc2c20e8861930214e21afc6a8a692a2", size = 225478, upload-time = "2026-08-26T14:58:12.543Z" }, + { url = "https://files.pythonhosted.org/packages/3e/47/0d63d4168536b4682c9d19b7399443b1176f25dbb68878374fa716670230/websockets-17.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2786cbd273ab69c22612db8a41229ddf2c158060b17b5928884bf388d07887f3", size = 226588, upload-time = "2026-08-26T14:58:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/844bd0b6386fc81ed6a55f4b6dd26f01c6987eda205afa10175ea12b2164/websockets-17.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c323fc3be1dc3f87f6c59458cb7d9e13dcbbf971d6c3f3e2bbaf58d3bfcdfe", size = 224336, upload-time = "2026-08-26T14:58:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/96/18/03709c84bc88ec4dcea68d4be4ccd07d611073dec111203a5bf45af8809d/websockets-17.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12c8e2b25df59755954a04dfa09c990b96691025aaf7eafd19ed6da24b09c18d", size = 225197, upload-time = "2026-08-26T14:58:17.141Z" }, + { url = "https://files.pythonhosted.org/packages/27/cf/0d1c694b6466c89e875b85b32b51312c472cf6708eee91914866f5087dde/websockets-17.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f58f58b4b29bbea2a3635e2c56eff4a3adab011fe383802a9e542e31b97085fc", size = 225493, upload-time = "2026-08-26T14:58:18.521Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f5/99857c3dd9676749f33e3668665a34ad6099505fb8d75eb084f49f7807a9/websockets-17.1-cp315-cp315t-win32.whl", hash = "sha256:f78a3ffb1994304db2c0c4588e4d1a518079b557054fa3bb985a6f5e50ff49a3", size = 217130, upload-time = "2026-08-26T14:58:20.037Z" }, + { url = "https://files.pythonhosted.org/packages/2c/84/77599922ab441bfe61508f97dab2c71f8e114d31793993ea54011db16199/websockets-17.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ad68c28a27246fed109a4409393d677b7e1388345cbbd2f5aee5c182d8506110", size = 217448, upload-time = "2026-08-26T14:58:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3c/8b9a225b523f06a9389be81f1b0ab07c49bec6014742e6aa359c1f920f1f/websockets-17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81", size = 217372, upload-time = "2026-08-26T14:58:22.807Z" }, + { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" }, +] + +[[package]] +name = "yarl" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" }, + { url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" }, + { url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" }, + { url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" }, + { url = "https://files.pythonhosted.org/packages/80/cf/54023edfab7aa773b860503db0c56e962ccab0922803ee97988c176ea090/yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e", size = 143975, upload-time = "2026-09-15T19:32:16.416Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a8/e6c1be0e6761d0f2d10bbf33a3e1e02b99dc83874d92945d7b461a72481e/yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f", size = 104018, upload-time = "2026-09-15T19:32:18.364Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac", size = 104156, upload-time = "2026-09-15T19:32:20.459Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5f/ed1538bcd06009fe990d6d283dd7667f639e62a81e35c6d8c6ef6c08fb3c/yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c", size = 116025, upload-time = "2026-09-15T19:32:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/a2/af/2185daf56b99830d3356ecfada46faaa49945de6626e842b7728088d4980/yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c", size = 106985, upload-time = "2026-09-15T19:32:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/bc1ae564fb4b04a30b6a8f250e787772581c57e4c3d5cf07ac3359de3103/yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743", size = 123030, upload-time = "2026-09-15T19:32:27.084Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/e2afcde10d74e53b3fa889960991efb3019beda2b1682a01de720a302056/yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f", size = 126765, upload-time = "2026-09-15T19:32:29.332Z" }, + { url = "https://files.pythonhosted.org/packages/a2/be/415b00c0fe5a0615b062a456b26623d7ec91c2bee20faea1a14045aa0469/yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57", size = 117199, upload-time = "2026-09-15T19:32:31.49Z" }, + { url = "https://files.pythonhosted.org/packages/97/27/3d8c63ddd3e8bcfd033748ab93876678ce59bacd66e4cb1ed851c9c5b37e/yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453", size = 115187, upload-time = "2026-09-15T19:32:34.137Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/7a81d0be1a502a26a0d4326c6f2ecb736c824f570ea1c6529f2b0b227b50/yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc", size = 116085, upload-time = "2026-09-15T19:32:36.438Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/39fff459916aa0fab42215dc47b759586fd80f94aa56dfc4a7c15ba6e0dc/yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f", size = 107996, upload-time = "2026-09-15T19:32:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c0/39/80b9a55a3335590451d9ecf3eb593a8c635351f4c905ef056d7e8a8fd9e7/yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8", size = 122549, upload-time = "2026-09-15T19:32:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/42/7d/a179c6757818bb59372a4adafd09f7f26a3b4a0f04c3ae404b544c0b0c82/yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868", size = 115107, upload-time = "2026-09-15T19:32:43.072Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/a773ac867e4ab53a98ed98e5cefe3bae31e6f550252ca9d1de266f1a40c5/yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62", size = 120666, upload-time = "2026-09-15T19:32:45.061Z" }, + { url = "https://files.pythonhosted.org/packages/bc/41/52be6505e85b0f76b4f85b01b5de7e06a0512201abc2c95e14e099549174/yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233", size = 117505, upload-time = "2026-09-15T19:32:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/f5/01/349c0386caedbbe488d519f252df54efac8a1459282d466c474bdd84a620/yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b", size = 103446, upload-time = "2026-09-15T19:32:49.615Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f0/8ec63180f77912f0dc4e5a42760cb8c08d20da1d5ace3578a01b84d1f3d8/yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414", size = 99159, upload-time = "2026-09-15T19:32:51.686Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/92d2220d6886b70ab1ed8579533ac2af2dfac716d5d929001daff7986df9/yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa", size = 150071, upload-time = "2026-09-15T19:32:53.911Z" }, + { url = "https://files.pythonhosted.org/packages/64/fc/b245e448124bcda9340df38e3553fa222b50260fca027a84095e9bd8642d/yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287", size = 106780, upload-time = "2026-09-15T19:32:56.443Z" }, + { url = "https://files.pythonhosted.org/packages/51/e2/9a6ce2e334ebf218a30335ae76fb1696459430d42f733b8cb0d7d65b84d3/yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94", size = 107361, upload-time = "2026-09-15T19:32:58.827Z" }, + { url = "https://files.pythonhosted.org/packages/ed/70/66e8c76b569b450d16e190f15071c916c3df70b0e33927e415ac497cf0c2/yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9", size = 114396, upload-time = "2026-09-15T19:33:02.24Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/0d82838a05c57fdc05bc8b66e8c92dcc0df15e27463a5f163142d521c682/yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0", size = 104882, upload-time = "2026-09-15T19:33:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/86/d4/ea08615c4edaa6049a13a2f1128944d068d1893abda7d708d4d7ea01599a/yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa", size = 119485, upload-time = "2026-09-15T19:33:06.583Z" }, + { url = "https://files.pythonhosted.org/packages/1a/82/0898bdce9b1ae403b308b9c733d0d24af4a3464270c2c081f457b16c3e0d/yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b", size = 122490, upload-time = "2026-09-15T19:33:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/97d79b81c342b78246cfedb74809e68841f3198d21653e10d3232bd9c622/yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6", size = 115336, upload-time = "2026-09-15T19:33:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/2577896554cd310dc470adb6da0b7dd0b435cb63e2565204a7ac240e504c/yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee", size = 111825, upload-time = "2026-09-15T19:33:13.204Z" }, + { url = "https://files.pythonhosted.org/packages/29/6b/7ac49d8ba84a5c4bd73415a4c949d22c749cb3762579b3d50e48019a78aa/yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0", size = 114655, upload-time = "2026-09-15T19:33:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/e5/18/e5942a16723f5b72f9b1297fd5a85a54f6300cd15c0dcb5005b90cd89156/yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7", size = 106395, upload-time = "2026-09-15T19:33:17.599Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2d/549fa46240781513ebc47ae7eb418df428a163a2a3d644cc9cbb3ecb7846/yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b", size = 119277, upload-time = "2026-09-15T19:33:19.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/16/4763f78dcdc0b3b9fb3842b04afe72b9320857c6a69300c62a0eab03d119/yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d", size = 112504, upload-time = "2026-09-15T19:33:22.464Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b4/974e3edfe0d188393ce1cb9de400111c63fe61f4eb3b772a500d84c970d1/yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3", size = 116243, upload-time = "2026-09-15T19:33:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/b0/aa/157b940428da80c104ca09666a740e51c94963df65d5b112e06b52e4d7a8/yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3", size = 115822, upload-time = "2026-09-15T19:33:26.886Z" }, + { url = "https://files.pythonhosted.org/packages/7e/af/19fbdce41412e1b96825544cc52cd7029d3724655d0988237972f078bd29/yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371", size = 107386, upload-time = "2026-09-15T19:33:29.009Z" }, + { url = "https://files.pythonhosted.org/packages/2a/99/f6431c8968e89be608d74b28ae2d024521b2953f27dd44e0dece5e04f67a/yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6", size = 102094, upload-time = "2026-09-15T19:33:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3b/4f51eab40c2eabea6c3d5b121dff4b8988dc35087732ffede12d2be8b8dd/yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc", size = 143875, upload-time = "2026-09-15T19:33:33.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/05/bbd58fc063f5f299a883f810760b265ca26c8167c91cb9a494d0fe2387e1/yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc", size = 104028, upload-time = "2026-09-15T19:33:36.22Z" }, + { url = "https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b", size = 104041, upload-time = "2026-09-15T19:33:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/884beab53ed88c7247d1671972b5ef116f7351fe0c7e6de8c3558372cb16/yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459", size = 116018, upload-time = "2026-09-15T19:33:44.265Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/1a91b685afb55cc18608443ace95280e96e263a97565811732b6788d3269/yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d", size = 107033, upload-time = "2026-09-15T19:33:46.45Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/58b379fcb1d68d907b7fcf75200c44321896509b2a6a74abbb4b19d864d2/yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26", size = 123257, upload-time = "2026-09-15T19:33:48.631Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2d/1fe96cf5c2aeab10095e48f38585cf5a8451fb7253234822398e52aa5336/yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273", size = 126745, upload-time = "2026-09-15T19:33:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf", size = 117255, upload-time = "2026-09-15T19:33:53.456Z" }, + { url = "https://files.pythonhosted.org/packages/2f/72/0faa30e02605d56127d42bb987dcc97da3863b7bf70b9bfbf5f739c05e30/yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57", size = 115166, upload-time = "2026-09-15T19:33:55.669Z" }, + { url = "https://files.pythonhosted.org/packages/8c/90/9a46eac564c437e128285c5c1d7bb385d268394e9209d84f6bf4a14471ef/yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3", size = 116082, upload-time = "2026-09-15T19:33:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/ad/38/4b1a686a3758878f93d2f1ea943f5a165f3555769cd16e761cfd0efdca17/yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2", size = 108048, upload-time = "2026-09-15T19:34:00.25Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/f348eb967f31a58348612a2b93bd8a2ab664548e2b5e879cac7f592f7201/yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9", size = 122775, upload-time = "2026-09-15T19:34:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e9/4f7b79700f88cb9e8bb66f8b54f9bce1844c013a2c39fdc47112e9334c95/yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f", size = 115096, upload-time = "2026-09-15T19:34:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/cf/37/f9cb020331997d3eb887bd28d5410ecfd3d80bf163c23d7cec490d78dade/yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b", size = 120655, upload-time = "2026-09-15T19:34:07.382Z" }, + { url = "https://files.pythonhosted.org/packages/12/83/52fceb22891a41f168db7ec22fd1d81e06b6a0b8d9f70921bd3e785defd0/yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c", size = 117488, upload-time = "2026-09-15T19:34:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5c/ce6c4ff1247fcbe4b33d462c23a097106d909b173fde7042bc52290466e2/yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee", size = 103434, upload-time = "2026-09-15T19:34:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/766a906b0704fb26d52b19dc22bed48a8ba0544203b70dcf44da350e8194/yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d", size = 99155, upload-time = "2026-09-15T19:34:15.132Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d3/a1d09b32cb6ab14f66b44939f5b4255b8b9e747aef3974af1d5d80ccc2fd/yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8", size = 149284, upload-time = "2026-09-15T19:34:17.452Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/fe554d879692650bca70bfbc0df124e82e4d2bb7456f698c7756f1279a96/yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4", size = 106399, upload-time = "2026-09-15T19:34:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4b/e7af56177ac8d40094c82d7728224c0b8472157d50d362e5fb3b014b2bc8/yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec", size = 106968, upload-time = "2026-09-15T19:34:23.619Z" }, + { url = "https://files.pythonhosted.org/packages/da/4f/2df41fd738d46f23ef829ae8b4468d94bb6070038fc6dfab6165ed44fea8/yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487", size = 114717, upload-time = "2026-09-15T19:34:25.879Z" }, + { url = "https://files.pythonhosted.org/packages/69/ea/002b66df53bbd1aed1c23358ff99c9bdc744fe3f4d2740e1b2fdd7192885/yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8", size = 105198, upload-time = "2026-09-15T19:34:28.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7c/95c8bc0c8f97d71e59c94525ad60d76f5c57d3f2820f08137ca8b9f0542a/yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75", size = 120271, upload-time = "2026-09-15T19:34:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/6fe9da56ec77927baa669fd86c39c567ce6205bab53d581082c6744c8ae7/yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3", size = 123572, upload-time = "2026-09-15T19:34:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/8b/83/35f222d17fa70a14c7c74fdf112ccf5515e0c2a87082b1f9b99f7693bf57/yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225", size = 115228, upload-time = "2026-09-15T19:34:35.344Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/fbaaf619423578a7d898d0f226ae47bc1906c293865c416d83c17b828b0e/yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209", size = 111618, upload-time = "2026-09-15T19:34:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/ba/78/7383278f1b3cf8e0496bd95b3281a7b09b89217b6b428db24c6b99b3deca/yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3", size = 114839, upload-time = "2026-09-15T19:34:40.099Z" }, + { url = "https://files.pythonhosted.org/packages/62/49/5506e5b6d29aab91bd845cc9016d88c3d3f81b81bc242b8100bdd5737825/yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d", size = 106212, upload-time = "2026-09-15T19:34:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/19877c193b7c5929f4b07118c18bbe390f3fadd0f59dd98c0cd12b31fa5c/yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c", size = 119985, upload-time = "2026-09-15T19:34:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6c/0a46fbbf9ecbcbd0cc20d2193394254b9e19817f22c814aab60f99847400/yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde", size = 112081, upload-time = "2026-09-15T19:34:47.45Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/93f5d471230c74ccd06255d0842739f86551f937f9e63a5e947853c6244a/yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64", size = 116995, upload-time = "2026-09-15T19:34:49.972Z" }, + { url = "https://files.pythonhosted.org/packages/2b/80/c386593035ee3f9c6c6af0847b5578f2830c674794a9d7701b744a3ebd42/yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f", size = 115570, upload-time = "2026-09-15T19:34:52.628Z" }, + { url = "https://files.pythonhosted.org/packages/38/02/eef443559563ef8f2e10469387b8b1e97cb5efee95b288a56da60801f7ee/yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd", size = 106811, upload-time = "2026-09-15T19:34:54.935Z" }, + { url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, +]