Semaine 11

This commit is contained in:
gauvainboiche
2026-07-11 21:38:00 +02:00
parent d3d2416dea
commit 0d071d2843
281 changed files with 54412 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Clé secrète Flask : utilisée pour signer les sessions et flash messages.
# Générer avec : python -c "import secrets; print(secrets.token_hex(32))"
FLASK_SECRET_KEY=hex(32)
# Mode debug (True/False). Désactiver en production.
FLASK_DEBUG=False
# Hôte et port d'écoute Flask
FLASK_HOST=127.0.0.1
FLASK_PORT=5000
# Chemin optionnel vers la base SQLite (relatif au projet)
DATABASE_PATH=databases/inventory.db
+40
View File
@@ -0,0 +1,40 @@
# --- Secrets ---
.env
.env.*
!.env.example
# --- Python ---
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.venv/
venv/
env/
.eggs/
*.egg-info/
dist/
build/
# --- Base de données locale ---
databases/*.db
databases/*.sqlite
databases/*.sqlite3
# --- IDE / OS ---
.idea/
.vscode/
*.swp
.DS_Store
Thumbs.db
# --- Logs ---
*.log
logs/
# --- Tests / coverage ---
.pytest_cache/
.coverage
htmlcov/
.tox/
+1
View File
@@ -0,0 +1 @@
3.14
Binary file not shown.
@@ -0,0 +1,204 @@
# Exercice 3.4 — Développer une application Flask avec API REST, interface Jinja2 et base SQLite3
## Objectifs pédagogiques
À l'issue de cet exercice, vous serez capable de :
* créer une application Flask combinant une API REST et une interface Web ;
* utiliser une base de données SQLite3 pour stocker des informations ;
* réaliser les opérations essentielles d'un CRUD (Create, Read, Update, Delete) ;
* concevoir une interface HTML dynamique avec Jinja2 ;
* créer un formulaire HTML permettant d'ajouter des données ;
* consommer une API REST avec la bibliothèque `requests` ;
* gérer correctement les erreurs de communication avec une API.
---
# Contexte
Une entreprise souhaite développer une petite application permettant de gérer son parc d'équipements informatiques.
L'application devra être accessible de deux façons :
* par une API REST destinée à d'autres applications ;
* par une interface Web destinée aux utilisateurs.
Toutes les données devront être enregistrées dans une base de données SQLite3.
---
# Travail demandé
## Étape 1 — Préparer le projet
* Créez un nouveau projet Python et mettez en place un environnement virtuel.
* Installez les bibliothèques nécessaires au développement de l'application.
* Organisez votre projet de manière à séparer le code Python, les templates HTML et la base de données.
---
## Étape 2 — Créer la base de données
Créez une base SQLite3.
Cette base devra contenir une table permettant de stocker les équipements.
Chaque équipement devra posséder au minimum :
* un identifiant unique ;
* un nom ;
* un état (actif ou inactif).
Vérifiez que la table est correctement créée.
---
## Étape 3 — Initialiser Flask
Créez une application Flask.
Assurez-vous que celle-ci démarre correctement et qu'une première page de test est accessible depuis un navigateur.
---
## Étape 4 — Accéder à la base de données
Écrivez les fonctions permettant :
* d'ouvrir une connexion à SQLite ;
* d'exécuter une requête SQL ;
* de récupérer les résultats ;
* de fermer correctement la connexion.
Ces fonctions devront être réutilisées dans toute l'application.
---
## Étape 5 — Développer l'API REST
Créez les routes permettant d'exposer les données sous forme JSON.
Votre API devra permettre :
* d'obtenir la liste complète des équipements ;
* d'ajouter un nouvel équipement.
Vérifiez le bon fonctionnement de ces routes avec un navigateur ou un outil de test d'API.
---
## Étape 6 — Réaliser l'interface Web
Ajoutez une nouvelle page Web affichant le parc des équipements.
Cette page devra être générée avec un template Jinja2.
Les données affichées devront provenir de la base SQLite.
---
## Étape 7 — Construire le template Jinja2
Dans votre template :
* affichez les équipements dans un tableau HTML ;
* utilisez une boucle Jinja2 pour parcourir les données ;
* affichez le nom et l'identifiant de chaque équipement ;
* affichez l'état de chaque équipement.
L'état devra être affiché différemment selon que l'équipement est actif ou inactif.
---
## Étape 8 — Ajouter un formulaire
Ajoutez au-dessus du tableau un formulaire HTML permettant de créer un nouvel équipement.
Le formulaire devra comporter :
* un champ de saisie pour le nom ;
* un bouton d'envoi.
Lors de la validation :
* les données devront être envoyées au serveur ;
* le nouvel équipement devra être enregistré dans SQLite ;
* la page devra être réaffichée avec la nouvelle liste.
---
## Étape 9 — Ajouter des actions sur les équipements
Pour chaque ligne du tableau, ajoutez des boutons permettant :
* de modifier l'état de l'équipement (actif ↔ inactif) ;
* de supprimer l'équipement.
Après chaque action, l'utilisateur devra être redirigé vers la liste mise à jour.
---
## Étape 10 — Développer un client Python
Créez un second programme Python jouant le rôle de client.
Ce programme devra communiquer avec l'API REST à l'aide de la bibliothèque `requests`.
Il devra :
1. récupérer la liste des équipements ;
2. afficher cette liste dans le terminal ;
3. ajouter un nouvel équipement via l'API ;
4. récupérer de nouveau la liste ;
5. afficher le résultat.
---
## Étape 11 — Gérer les erreurs
Tous les appels HTTP devront être sécurisés.
Votre programme devra notamment :
* définir un délai maximal d'attente pour chaque requête ;
* vérifier que la réponse du serveur est valide ;
* afficher un message clair si le serveur est indisponible ;
* éviter tout arrêt brutal du programme en cas d'erreur réseau.
---
## Étape 12 — Vérifier le fonctionnement
Vérifiez que les opérations suivantes fonctionnent correctement :
* création de la base de données ;
* ajout d'un équipement via le formulaire Web ;
* affichage dynamique des données dans le template Jinja2 ;
* modification de l'état d'un équipement ;
* suppression d'un équipement ;
* consultation de l'API REST ;
* ajout d'un équipement via le client Python.
---
# Livrables attendus
Le projet devra contenir au minimum :
* le fichier principal de l'application Flask ;
* le client Python utilisant `requests` ;
* la base SQLite3 (ou son script de création) ;
* le dossier `templates` contenant les pages Jinja2 ;
* un document `USAGE-IA.md` décrivant l'utilisation éventuelle d'une IA générative pendant le développement.
---
# Critères d'évaluation
L'évaluation portera sur les points suivants :
| Critère | Points d'attention |
| ------------------- | ---------------------------------------------------------------------- |
| Base SQLite | Création correcte et accès fonctionnel |
| API REST | Routes opérationnelles et réponses JSON conformes |
| CRUD | Ajout, consultation, modification et suppression fonctionnels |
| Interface Jinja2 | Utilisation correcte des variables, boucles et conditions |
| Formulaire HTML | Enregistrement correct des données dans SQLite |
| Client `requests` | Appels HTTP correctement réalisés |
| Gestion des erreurs | Timeout, vérification des réponses et traitement propre des exceptions |
| Qualité du code | Organisation, lisibilité et séparation des responsabilités |
+80
View File
@@ -0,0 +1,80 @@
# Exo 3.4 "Consommer l'API" - BOICHÉ Gauvain
## Usage de l'IA
### Raison 1
Pour convertir la structure de SQLAlchemy en SQLite. Parce que, comme un imbécile, j'ai tout fait en SQLAlchemy, sans percuter.
#### Ce que j'en ai retenu
La somme de la conversion était colossale.
Déjà, de nombreuses méthodes (pas au sens Python du terme) m'étant inconnues ont été intégrées. J'ai lu religieusement pour comprendre, et ça va, le sens ne m'échappe pas.
Celle qui m'a fait le plus tiquer c'est `flash()`. Les strings disent `Flashes a message to the next request. In order to remove the flashed message from the session and to display it to the user, the template has to call get_flashed_messages.` C'est spécial. Je me rends compte aussi que mes propres fonctions n'ont pas de """docstring""", mais pour autant, je pense ne pas en avoir besoin.
Un de mes mentors m'avait dit : "un bon code se passe de commentaire". Et ma convention de nommage semble assez exemplaire pour s'en passer.
### Raison 2
Pour générer les fichiers .env et .gitignore, parce que ça me saoule aussi de fouiller dans mes dossiers jusqu'à satsifaction. Et faire une méthode pour ne rien lancer sans ces valeurs.
#### Ce que j'en ai retenu
Y a rien à retenir de particulier. Sauf peut-être un genre de dossier général à la racine de mes projets avec des patrons tout fait.
Quand à la fonction `_required_env`, à la base j'avais fait un simple `try: except:` mais ça semble mieux comme ça.
Et par ricochet, j'ai vu qu'on pouvait définir des variables dans l'application "Flask" avec `.config`.
### Raison 3
Générer du code CSS.
#### Ce que j'en ai retenu
La raison pour laquelle j'ai lâché mes cours CodeCademy sur le Frontend. Ca me gonfle.
### Raison 4
Choisir de faire ou non une réinitialisation de la DB en relançant app.py
#### Ce que j'en ai retenu
J'ai plusieurs fois voulu faire un choix multiples en Python sans jamais me renseigner dessus, et j'étais trop avancé pour refactoriser ça moi-même. Visiblement ça n'existe pas trop dans l'état, ça marche avec un "O/N" classique. Peut-être existe-t-il une solution (choisir avec les flèches du clavier) mais ça demande certainement un genre de moteur visuel. Je ne sais pas du moins. Je n'ai pas insisté, mais de fait je me trouve stupide de demander à l'IA ce que je savais déjà faire. Après, j'ignorais que je connaissais déjà la seule solution, donc au moins je retiens que Python ne gère pas forcément tout.
### Raison 5
Ecrire les commandes de test, notamment sur la robustesse.
#### Ce que j'en ai retenu
La sortie JSON formatée par python m'intéresse. Je ne pensais pas que Python pouvait servir aussi à ça. Dans le quotidien ça peut être utile à savoir.
Et surtout, je voulais juste une liste de commandes curl, mais l'IA m'a proposé directement après, sans lui demander, un script .sh (joint). Pourquoi m'embêter avec un .md et des copier/coller ? Je dois y repenser à chaque fois. Faire des sorties automatiques. Je le fais au travail mais jamais en exercice.
Partisan du moindre effort ? Oui, et ça fait de moi un bon informaticien.
### Raison 6
Téléversement de mon projet finalisé (le .md de l'exercice, les .py et les .html ainsi qu'une capture d'écran de l'arborescence) sur trois IA spécialisées différentes (QwenCoder 3.7 Plus, MiniMax M3, Kimi K2.6 [on le sent que je contrebalance la domination américaine par de la domination chinoise, ou pas du tout ?]) avec une demande de revue.
#### Les résultats
Les trois IA fournissent des rapports cohérents (ce qui est rassurant) même si chacune courbe son jugement par son paradigme interne :
- Qwen se concentre sur la base
- Kimi relève les soucis d'imports circulaires et les potentiels points de blocage concrets actuels
- MiniMax a relevé les potentiels problèmes futurs en anticipant une application qui prend de l'ampleur
Les bogues relevés :
- Sérialisation JSON menant à une potentielle `TypeError` (que je n'ai pas constatée)
- Fonctions critiques potentiellement bloquantes
- Fonctions critiques dans des boucles non sécurisées
- Coquilles et oublis de lignes à rajouter/retirer
#### Ce que j'ai modifié en conséquence
Corrections des bogues critiques, re-test et validation.
+234
View File
@@ -0,0 +1,234 @@
import os
from pathlib import Path
from dotenv import load_dotenv
from flask import Flask, jsonify, request, render_template, redirect, url_for, flash
from classes import Equipment, Interface
from database import (
init_database,
reset_database,
prompt_reset,
fetch_all_equipments,
fetch_equipment_by_id,
insert_equipment,
update_equipment,
delete_equipment,
)
load_dotenv()
app = Flask(__name__)
def _required_env(key: str) -> str:
value = os.environ.get(key)
if not value:
raise RuntimeError(
f"Variable d'environnement {key!r} manquante. "
f"Vérifiez votre fichier .env (voir .env.example)."
)
return value
app.config["SECRET_KEY"] = _required_env("FLASK_SECRET_KEY")
app.config["DEBUG"] = os.environ.get("FLASK_DEBUG", "False").lower() in {"1", "true", "yes"}
app.config["DATABASE_PATH"] = os.environ.get("DATABASE_PATH", "databases/inventory.db")
ALLOWED_TYPES = {"Server", "Instance", "Switch", "Gateway", "Router", "WAF", "Database"}
def _parse_form_equipment(form) -> tuple[Equipment | None, str | None]:
hostname = (form.get("hostname") or "").strip()
if not hostname:
return None, "Hostname requis."
eq_type = form.get("type", "Server")
if eq_type not in ALLOWED_TYPES:
return None, f"Type invalide : {eq_type}."
ip = (form.get("ip_address") or "").strip() or "255.255.255.0"
is_active = form.get("is_active") == "on"
latency_raw = (form.get("latency") or "").strip()
latency = None
if latency_raw:
try:
latency = float(latency_raw)
if latency < 0:
return None, "Latence doit être positive."
except ValueError:
return None, "Latence invalide."
ports = form.getlist("ports[]")
services = form.getlist("services[]")
interfaces: list[Interface] = []
for port_raw, service in zip(ports, services):
if not port_raw:
continue
try:
port = int(port_raw)
if not (0 <= port <= 65535):
return None, f"Port invalide : {port_raw}."
except ValueError:
return None, f"Port non numérique : {port_raw}."
if not service.strip():
return None, "Service requis pour chaque interface."
interfaces.append(Interface(port=port, service=service.strip()))
return Equipment(
hostname=hostname,
type=eq_type,
ip_address=ip,
is_active=is_active,
latency=latency,
interfaces=interfaces,
), None
def _equipment_to_dict(eq: Equipment) -> dict:
return {
"id": eq.id,
"hostname": eq.hostname,
"type": eq.type,
"ip_address": eq.ip_address,
"is_active": eq.is_active,
"latency": eq.latency,
"interfaces": [
{"id": i.id, "port": i.port, "service": i.service}
for i in eq.interfaces
],
}
# Partie FRONT
@app.route('/')
def index():
return render_template('index.html')
@app.route('/equipments', methods=['GET'])
def equipments_page():
equipments = fetch_all_equipments()
return render_template('equipments.html', equipments=equipments, types=sorted(ALLOWED_TYPES))
@app.route('/equipments', methods=['POST'])
def create_equipment_form():
eq, err = _parse_form_equipment(request.form)
if err:
flash(err, "error")
return redirect(url_for('equipments_page'))
insert_equipment(eq)
flash(f"Équipement « {eq.hostname} » créé.", "success")
return redirect(url_for('equipments_page'))
@app.route('/equipments/<int:equipment_id>/toggle', methods=['POST'])
def toggle_equipment(equipment_id: int):
eq = fetch_equipment_by_id(equipment_id)
if eq is None:
flash(f"Équipement {equipment_id} introuvable.", "error")
return redirect(url_for('equipments_page'))
eq.is_active = not eq.is_active
update_equipment(eq)
flash(f"État de « {eq.hostname} » mis à jour.", "success")
return redirect(url_for('equipments_page'))
@app.route('/equipments/<int:equipment_id>/delete', methods=['POST'])
def delete_equipment_form(equipment_id: int):
eq = fetch_equipment_by_id(equipment_id)
if eq is None:
flash(f"Équipement {equipment_id} introuvable.", "error")
return redirect(url_for('equipments_page'))
delete_equipment(equipment_id)
flash(f"Équipement « {eq.hostname} » supprimé.", "success")
return redirect(url_for('equipments_page'))
# Partie API
@app.get("/api/equipments")
def list_equipments_api():
return jsonify([_equipment_to_dict(eq) for eq in fetch_all_equipments()])
@app.get("/api/equipments/<int:equipment_id>")
def get_equipment_api(equipment_id: int):
eq = fetch_equipment_by_id(equipment_id)
if eq is None:
return jsonify({"error": "Équipement introuvable."}), 404
return jsonify(_equipment_to_dict(eq))
@app.post("/api/equipments")
def create_equipment_api():
data = request.get_json(silent=True) or {}
hostname = (data.get("hostname") or "").strip()
if not hostname:
return jsonify({"error": "Hostname requis."}), 400
eq_type = data.get("type", "Server")
if eq_type not in ALLOWED_TYPES:
return jsonify({"error": f"Type invalide : {eq_type}."}), 400
eq = Equipment(
hostname=hostname,
type=eq_type,
ip_address=(data.get("ip_address") or "255.255.255.0").strip(),
is_active=bool(data.get("is_active", True)),
latency=data.get("latency"),
interfaces=[],
)
new_id = insert_equipment(eq)
eq.id = new_id
return jsonify(_equipment_to_dict(eq)), 201
@app.put("/api/equipments/<int:equipment_id>")
def update_equipment_api(equipment_id: int):
eq = fetch_equipment_by_id(equipment_id)
if eq is None:
return jsonify({"error": f"Équipement {equipment_id} introuvable."}), 404
data = request.get_json(silent=True) or {}
hostname = (data.get("hostname") or "").strip()
if not hostname:
return jsonify({"error": "Hostname requis."}), 400
eq_type = data.get("type", "Server")
if eq_type not in ALLOWED_TYPES:
return jsonify({"error": f"Type invalide : {eq_type}."}), 400
latency = data.get("latency")
if latency is not None:
try:
latency = float(latency)
if latency < 0:
return jsonify({"error": "Latence doit être positive."}), 400
except (ValueError, TypeError):
return jsonify({"error": "Latence invalide."}), 400
eq.hostname = hostname
eq.type = eq_type
eq.ip_address = (data.get("ip_address") or "255.255.255.0").strip()
eq.is_active = bool(data.get("is_active", True))
eq.latency = latency
update_equipment(eq)
return jsonify(_equipment_to_dict(eq)), 200
@app.patch("/api/equipments/<int:equipment_id>/toggle")
def toggle_equipment_api(equipment_id: int):
eq = fetch_equipment_by_id(equipment_id)
if eq is None:
return jsonify({"error": f"Équipement {equipment_id} introuvable."}), 404
eq.is_active = not eq.is_active
update_equipment(eq)
return jsonify(_equipment_to_dict(eq)), 200
@app.delete("/api/equipments/<int:equipment_id>")
def delete_equipment_api(equipment_id: int):
eq = fetch_equipment_by_id(equipment_id)
if eq is None:
return jsonify({"error": f"Équipement {equipment_id} introuvable."}), 404
delete_equipment(equipment_id)
return jsonify({"message": f"Équipement « {eq.hostname} » supprimé."}), 200
if __name__ == '__main__':
host = os.environ.get("FLASK_HOST", "127.0.0.1")
port = int(os.environ.get("FLASK_PORT", "5000"))
if prompt_reset():
reset_database()
print("Base réinitialisée.")
else:
init_database()
app.run(host=host, port=port, debug=app.config["DEBUG"])
+19
View File
@@ -0,0 +1,19 @@
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class Interface:
id: Optional[int] = None
port: int = 0
service: str = "[SERVICE]"
equipment_id: Optional[int] = None
@dataclass
class Equipment:
id: Optional[int] = None
hostname: str = "[HOSTNAME]"
type: str = "Server"
ip_address: str = "255.255.255.0"
is_active: bool = True
latency: Optional[float] = None
interfaces: List[Interface] = field(default_factory=list)
+92
View File
@@ -0,0 +1,92 @@
import sys
import requests
BASE_URL = "http://127.0.0.1:5000"
TIMEOUT = 5
def list_equipments() -> list[dict] | None:
try:
response = requests.get(f"{BASE_URL}/api/equipments", timeout=TIMEOUT)
response.raise_for_status()
except requests.exceptions.Timeout:
print(f"[ERREUR] Délai d'attente dépassé ({TIMEOUT}s) lors de GET /api/equipments.")
return None
except requests.exceptions.ConnectionError:
print(f"[ERREUR] Impossible de joindre le serveur {BASE_URL}. Est-il démarré ?")
return None
except requests.exceptions.HTTPError as e:
print(f"[ERREUR] Réponse HTTP invalide : {e}")
return None
except requests.exceptions.RequestException as e:
print(f"[ERREUR] Échec de la requête : {e}")
return None
try:
data = response.json()
except ValueError:
print("[ERREUR] Réponse non-JSON reçue du serveur.")
return None
if not isinstance(data, list):
print("[ERREUR] Format de réponse inattendu (liste attendue).")
return None
return data
def add_equipment(hostname: str, type_: str, ip_address: str) -> dict | None:
payload = {
"hostname": hostname,
"type": type_,
"ip_address": ip_address,
"is_active": True,
}
try:
response = requests.post(
f"{BASE_URL}/api/equipments",
json=payload,
timeout=TIMEOUT,
)
response.raise_for_status()
except requests.exceptions.Timeout:
print(f"[ERREUR] Délai d'attente dépassé ({TIMEOUT}s) lors de POST /api/equipments.")
return None
except requests.exceptions.ConnectionError:
print(f"[ERREUR] Impossible de joindre le serveur {BASE_URL}.")
return None
except requests.exceptions.HTTPError as e:
print(f"[ERREUR] Réponse HTTP invalide : {e}")
return None
except requests.exceptions.RequestException as e:
print(f"[ERREUR] Échec de la requête : {e}")
return None
try:
return response.json()
except ValueError:
print("[ERREUR] Réponse non-JSON reçue après création.")
return None
def main() -> int:
print("=== Liste initiale ===")
initial = list_equipments()
if initial is None:
return 1
for eq in initial:
print(f" #{eq['id']} {eq['hostname']} ({eq['type']}) - {'actif' if eq['is_active'] else 'inactif'}")
print("\n=== Ajout d'un équipement ===")
new_eq = add_equipment("client-test-01", "Router", "10.0.99.1")
if new_eq is None:
return 1
print(f" Créé : #{new_eq['id']} {new_eq['hostname']}")
print("\n=== Liste mise à jour ===")
updated = list_equipments()
if updated is None:
return 1
for eq in updated:
print(f" #{eq['id']} {eq['hostname']} ({eq['type']}) - {'actif' if eq['is_active'] else 'inactif'}")
return 0
if __name__ == "__main__":
sys.exit(main())
+157
View File
@@ -0,0 +1,157 @@
import sqlite3
from pathlib import Path
from contextlib import contextmanager
from typing import Any, Iterable, Optional
from classes import Equipment, Interface
BASE_DIR = Path(__file__).resolve().parent
DB_DIR = BASE_DIR / "databases"
DB_PATH = DB_DIR / "inventory.db"
SCHEMA = """
CREATE TABLE IF NOT EXISTS equipments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL DEFAULT '[HOSTNAME]',
type TEXT NOT NULL DEFAULT 'Server',
ip_address TEXT NOT NULL DEFAULT '255.255.255.0',
is_active INTEGER NOT NULL DEFAULT 1,
latency REAL
);
CREATE TABLE IF NOT EXISTS interfaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
port INTEGER NOT NULL,
service TEXT NOT NULL DEFAULT '[SERVICE]',
equipment_id INTEGER NOT NULL,
FOREIGN KEY (equipment_id) REFERENCES equipments(id) ON DELETE CASCADE
);
"""
def _ensure_db_dir() -> None:
DB_DIR.mkdir(parents=True, exist_ok=True)
@contextmanager
def get_connection():
_ensure_db_dir()
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
try:
yield conn
finally:
conn.close()
def init_database() -> None:
with get_connection() as conn:
conn.executescript(SCHEMA)
conn.commit()
def execute_query(query: str, params: Iterable[Any] = ()) -> list[sqlite3.Row]:
with get_connection() as conn:
cursor = conn.execute(query, tuple(params))
return cursor.fetchall()
def execute_modification(query: str, params: Iterable[Any] = ()) -> int:
with get_connection() as conn:
cursor = conn.execute(query, tuple(params))
conn.commit()
return cursor.rowcount
def _row_to_equipment(row: sqlite3.Row) -> Equipment:
return Equipment(
id=row["id"],
hostname=row["hostname"],
type=row["type"],
ip_address=row["ip_address"],
is_active=bool(row["is_active"]),
latency=row["latency"],
)
def _row_to_interface(row: sqlite3.Row) -> Interface:
return Interface(
id=row["id"],
port=row["port"],
service=row["service"],
equipment_id=row["equipment_id"],
)
def fetch_all_equipments() -> list[Equipment]:
rows = execute_query(
"SELECT id, hostname, type, ip_address, is_active, latency "
"FROM equipments ORDER BY id"
)
equipments = [_row_to_equipment(r) for r in rows]
for eq in equipments:
eq.interfaces = fetch_interfaces_for_equipment(eq.id) #type: ignore
return equipments
def fetch_equipment_by_id(equipment_id: int) -> Optional[Equipment]:
rows = execute_query(
"SELECT id, hostname, type, ip_address, is_active, latency "
"FROM equipments WHERE id = ?",
(equipment_id,),
)
if not rows:
return None
eq = _row_to_equipment(rows[0])
eq.interfaces = fetch_interfaces_for_equipment(eq.id) #type: ignore
return eq
def fetch_interfaces_for_equipment(equipment_id: int) -> list[Interface]:
rows = execute_query(
"SELECT id, port, service, equipment_id FROM interfaces "
"WHERE equipment_id = ? ORDER BY id",
(equipment_id,),
)
return [_row_to_interface(r) for r in rows]
def insert_equipment(eq: Equipment) -> int:
with get_connection() as conn:
cursor = conn.execute(
"INSERT INTO equipments (hostname, type, ip_address, is_active, latency) "
"VALUES (?, ?, ?, ?, ?)",
(eq.hostname, eq.type, eq.ip_address, int(eq.is_active), eq.latency),
)
eq_id = cursor.lastrowid
eq.id = eq_id # <-- ajout
for iface in eq.interfaces:
conn.execute(
"INSERT INTO interfaces (port, service, equipment_id) "
"VALUES (?, ?, ?)",
(iface.port, iface.service, eq_id),
)
conn.commit()
return eq_id #type:ignore
def update_equipment(eq: Equipment) -> bool:
return execute_modification(
"UPDATE equipments SET hostname=?, type=?, ip_address=?, "
"is_active=?, latency=? WHERE id=?",
(eq.hostname, eq.type, eq.ip_address, int(eq.is_active),
eq.latency, eq.id),
) > 0
def delete_equipment(equipment_id: int) -> bool:
return execute_modification(
"DELETE FROM equipments WHERE id = ?", (equipment_id,)
) > 0
def reset_database() -> None:
from init_db import main as seed
if DB_PATH.exists():
DB_PATH.unlink()
init_database()
seed()
def prompt_reset() -> bool:
while True:
choice = input("Réinitialiser la base de données ? [o/N] : ").strip().lower()
if choice in {"o", "oui", "y", "yes"}:
return True
if choice in {"", "n", "non", "no"}:
return False
print("Réponse invalide. Saisissez 'o' (oui) ou 'n' (non).")
# Gestion API
+67
View File
@@ -0,0 +1,67 @@
from random import choice, randint
from database import init_database, insert_equipment, fetch_all_equipments
from classes import Equipment, Interface
NUMBER_OF_EQUIPMENTS_TO_POPULATE = 15
COMMON_SERVICES: dict[str, int] = {
"FTP": 21,
"SSH": 22,
"Telnet": 23,
"SMTP": 25,
"DNS": 53,
"HTTP": 80,
"POP3": 110,
"NetBIOS": 139,
"IMAP": 143,
"HTTPS": 443,
"LDAPS": 636,
"RDP": 3389,
}
TYPES = [
"Server",
"Instance",
"Switch",
"Gateway",
"Router",
"WAF",
"Database",
]
def random_ipv4() -> str:
return ".".join(str(randint(0, 255)) for _ in range(4))
def load_words(path: str = "wordlist.txt") -> list[str]:
with open(path, "r", encoding="utf-8") as f:
return [line.strip() for line in f if line.strip()]
def random_hostname(words: list[str]) -> str:
triplet = "-".join(choice(words) for _ in range(3))
return f"{triplet}-{randint(1, 999):03d}"
def random_interfaces() -> list[Interface]:
count = randint(2, 5)
services = list(COMMON_SERVICES.items())
return [Interface(port=port, service=svc) for svc, port in
(choice(services) for _ in range(count))]
def main(n: int = NUMBER_OF_EQUIPMENTS_TO_POPULATE) -> None:
init_database()
if fetch_all_equipments():
print("Base déjà peuplée, peuplement ignoré.")
return
words = load_words()
for _ in range(n):
insert_equipment(Equipment(
hostname=random_hostname(words),
type=choice(TYPES),
ip_address=random_ipv4(),
is_active=True,
latency=round(randint(1, 500) / 10, 1),
interfaces=random_interfaces(),
))
print(f"{n} équipements insérés.")
if __name__ == "__main__":
main()
+12
View File
@@ -0,0 +1,12 @@
[project]
name = "3_4_API_REST"
version = "0.1.0"
description = "Add your description here"
readme = "USAGE-IA.md"
requires-python = ">=3.14"
dependencies = [
"dotenv>=0.9.9",
"flask>=3.1.3",
"requests>=2.34.2",
"sqlalchemy>=2.0.51",
]
+306
View File
@@ -0,0 +1,306 @@
# Exo 3.4 "Consommer l'API" - BOICHÉ Gauvain
## Requêtes type
### Lister
```
# Liste complète
curl -i http://127.0.0.1:5000/api/equipments
# Equipement précis
curl -i http://127.0.0.1:5000/api/equipments/1
# Equipement inexistant
curl -i http://127.0.0.1:5000/api/equipments/9999
# Sortie formatée
curl -s http://127.0.0.1:5000/api/equipments | python -m json.tool
# Compter les équipements
curl -s http://127.0.0.1:5000/api/equipments | python -c "import json,sys; print(len(json.load(sys.stdin)))"
# Filtrage par champ
curl -s http://127.0.0.1:5000/api/equipments | jq '.[] | select(.is_active==true) | {id, hostname}'
```
### Créer
```
# Normal
curl -i -X POST http://127.0.0.1:5000/api/equipments \
-H "Content-Type: application/json" \
-d '{"hostname":"curl-test-01","type":"router","ip_address":"10.0.50.1","is_active":true,"latency":12.5}'
# Mal formaté
curl -i -X POST http://127.0.0.1:5000/api/equipments \
-H "Content-Type: application/json" \
-d '{hostname: "sans-guillemets"}'
# Avec mentions obligatoires manquantes
curl -i -X POST http://127.0.0.1:5000/api/equipments \
-H "Content-Type: application/json" \
-d '{"type":"router"}'
```
### Patch partiel (actif <-> inactif)
```
curl -i -X PATCH http://127.0.0.1:5000/api/equipments/1/toggle
```
### Mise à jour
```
curl -i -X PUT http://127.0.0.1:5000/api/equipments/1 \
-H "Content-Type: application/json" \
-d '{"hostname":"serveur-prod-01","type":"server","ip_address":"10.0.10.1","is_active":true,"latency":8.3}'
```
### Supprimer
```
# Autorisé
curl -i -X DELETE http://127.0.0.1:5000/api/equipments/1
# Interdit
curl -i -X DELETE http://127.0.0.1:5000/api/equipments
```
## Réponses
### Lister
```
> curl -i http://127.0.0.1:5000/api/equipments
HTTP/1.1 200 OK
Server: Werkzeug/3.1.8 Python/3.14.6
Date: Wed, 08 Jul 2026 12:58:59 GMT
Content-Type: application/json
Content-Length: 6850
Connection: close
[
{
"hostname": "bird-dawn-peas-031",
"id": 1,
"interfaces": [
{
"id": 1,
"port": 25,
"service": "SMTP"
},
{
"id": 2,
"port": 21,
"service": "FTP"
},
{
"id": 3,
"port": 80,
"service": "HTTP"
},
{
"id": 4,
"port": 21,
"service": "FTP"
}
],
"ip_address": "242.48.158.201",
"is_active": true,
"latency": 33.6,
"type": "Router"
}, [...]
> curl -i http://127.0.0.1:5000/api/equipments/12
HTTP/1.1 200 OK
Server: Werkzeug/3.1.8 Python/3.14.6
Date: Wed, 08 Jul 2026 13:21:47 GMT
Content-Type: application/json
Content-Length: 314
Connection: close
{
"hostname": "barn-lamps-bottle-151",
"id": 12,
"interfaces": [
{
"id": 42,
"port": 53,
"service": "DNS"
},
{
"id": 43,
"port": 139,
"service": "NetBIOS"
}
],
"ip_address": "76.143.32.122",
"is_active": true,
"latency": 42.1,
"type": "Server"
}
> curl -i http://127.0.0.1:5000/api/equipments/1267
HTTP/1.1 404 NOT FOUND
Server: Werkzeug/3.1.8 Python/3.14.6
Date: Wed, 08 Jul 2026 13:22:01 GMT
Content-Type: application/json
Content-Length: 46
Connection: close
{
"error": "\u00c9quipement introuvable."
}
> curl -s http://127.0.0.1:5000/api/equipments | python -c "import json,sys; print(len(json.load(sys.stdin)))"
15
```
### Créer
```
> curl -i -X POST http://127.0.0.1:5000/api/equipments -H "Content-Type: application/json" -d '{"hostname":"curl-test-01","type":"Router","ip_address":"10.0.50.1","is_active":true,"latency":12.5}'
HTTP/1.1 201 CREATED
Server: Werkzeug/3.1.8 Python/3.14.6
Date: Wed, 08 Jul 2026 13:23:15 GMT
Content-Type: application/json
Content-Length: 154
Connection: close
{
"hostname": "curl-test-01",
"id": 16,
"interfaces": [],
"ip_address": "10.0.50.1",
"is_active": true,
"latency": 12.5,
"type": "Router"
}
> curl -i -X POST http://127.0.0.1:5000/api/equipments -H "Content-Type: application/json" -d '{"hostname":"curl-test-01","type":"router","ip_address":"10.0.50.1","is_active":true,"latency":12.5}'
HTTP/1.1 400 BAD REQUEST
Server: Werkzeug/3.1.8 Python/3.14.6
Date: Wed, 08 Jul 2026 13:23:04 GMT
Content-Type: application/json
Content-Length: 41
Connection: close
{
"error": "Type invalide : router."
}
> curl -i -X POST http://127.0.0.1:5000/api/equipments -H "Content-Type: application/json" -d '{"type":"Router"}'
HTTP/1.1 400 BAD REQUEST
Server: Werkzeug/3.1.8 Python/3.14.6
Date: Wed, 08 Jul 2026 13:23:58 GMT
Content-Type: application/json
Content-Length: 34
Connection: close
{
"error": "Hostname requis."
}
```
### Patch partiel (actif <-> inactif)
```
> curl -i -X PATCH http://127.0.0.1:5000/api/equipments/2/toggle
HTTP/1.1 200 OK
Server: Werkzeug/3.1.8 Python/3.14.6
Date: Wed, 08 Jul 2026 13:46:11 GMT
Content-Type: application/json
Content-Length: 387
Connection: close
{
"hostname": "gulf-manage-order-022",
"id": 2,
"interfaces": [
{
"id": 3,
"port": 21,
"service": "FTP"
},
{
"id": 4,
"port": 139,
"service": "NetBIOS"
},
{
"id": 5,
"port": 143,
"service": "IMAP"
}
],
"ip_address": "183.139.112.112",
"is_active": false,
"latency": 28.1,
"type": "Database"
}
```
### Mise à jour
```
> curl -i -X PUT http://127.0.0.1:5000/api/equipments/1 -H "Content-Type: application/json" -d '{"hostname":"serveur-prod-01","type":"Server","ip_address":"10.0.10.1","is_active":true,"latency":8.3}'
HTTP/1.1 200 OK
Server: Werkzeug/3.1.8 Python/3.14.6
Date: Wed, 08 Jul 2026 13:49:06 GMT
Content-Type: application/json
Content-Length: 297
Connection: close
{
"hostname": "serveur-prod-01",
"id": 1,
"interfaces": [
{
"id": 1,
"port": 53,
"service": "DNS"
},
{
"id": 2,
"port": 3389,
"service": "RDP"
}
],
"ip_address": "10.0.10.1",
"is_active": true,
"latency": 8.3,
"type": "Server"
}
```
### Supprimer
```
> curl -i -X DELETE http://127.0.0.1:5000/api/equipments/1
HTTP/1.1 200 OK
Server: Werkzeug/3.1.8 Python/3.14.6
Date: Wed, 08 Jul 2026 13:49:49 GMT
Content-Type: application/json
Content-Length: 80
Connection: close
{
"message": "\u00c9quipement \u00ab serveur-prod-01 \u00bb supprim\u00e9."
}
> curl -i -X DELETE http://127.0.0.1:5000/api/equipments
HTTP/1.1 405 METHOD NOT ALLOWED
Server: Werkzeug/3.1.8 Python/3.14.6
Date: Wed, 08 Jul 2026 13:50:04 GMT
Content-Type: text/html; charset=utf-8
Allow: OPTIONS, POST, HEAD, GET
Content-Length: 153
Connection: close
<!doctype html>
<html lang=en>
<title>405 Method Not Allowed</title>
<h1>Method Not Allowed</h1>
<p>The method is not allowed for the requested URL.</p>
```
@@ -0,0 +1,63 @@
:root {
--bg: #f5f5f7;
--card: #ffffff;
--border: #d2d2d7;
--accent: #0071e3;
--danger: #ff3b30;
--success: #34c759;
}
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg); margin: 2rem; }
nav { margin-bottom: 1.5rem; }
nav a { color: var(--accent); text-decoration: none; margin-right: 1rem; }
.flash { padding: .75rem 1rem; border-radius: 6px; margin-bottom: 1rem; }
.flash-success { background: #d1f5d3; color: #0a6b1f; }
.flash-error { background: #ffd5d2; color: #a0190d; }
form { background: var(--card); padding: 1.5rem; border-radius: 8px; border: 1px solid var(--border); }
form label { display: block; margin-bottom: .75rem; }
form input, form select { padding: .5rem; border: 1px solid var(--border); border-radius: 4px; }
fieldset { margin-top: 1rem; border: 1px solid var(--border); padding: 1rem; border-radius: 6px; }
.interface-row { display: flex; gap: .5rem; margin-bottom: .5rem; }
.interface-row input { flex: 1; }
.equipment-grid { list-style: none; padding: 0; display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.equipment { background: var(--card); padding: 1rem; border-radius: 8px; border: 1px solid var(--border); }
.equipment.inactive { opacity: .6; }
.equipment header { display: flex; justify-content: space-between; align-items: center; }
.badge { background: var(--accent); color: white; padding: .2rem .6rem; border-radius: 12px; font-size: .8rem; }
.equipment footer { display: flex; gap: .5rem; margin-top: 1rem; }
.equipment footer form { flex: 1; padding: 0; border: none; background: none; }
.equipment button { width: 100%; padding: .5rem; border: none; border-radius: 4px; cursor: pointer; color: white; }
.btn-toggle { background: var(--accent); }
.btn-delete { background: var(--danger); }
.empty { color: #6e6e73; font-style: italic; }
.equipments-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.equipments-table th,
.equipments-table td {
border: 1px solid #ddd;
padding: 0.5rem 0.75rem;
text-align: left;
vertical-align: top;
}
.equipments-table thead {
background: #f4f4f4;
}
.equipments-table .row-inactive {
opacity: 0.6;
background: #fafafa;
}
.status {
font-weight: 600;
}
.status-active { color: #1a7f37; }
.status-inactive { color: #b42318; }
.actions form { display: inline-block; margin-right: 0.25rem; }
.interfaces-list { margin: 0; padding-left: 1rem; }
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>{% block title %}Gestion du parc{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/equipments.css') }}">
</head>
<body>
<nav><a href="{{ url_for('index') }}">Accueil</a> | <a href="{{ url_for('equipments_page') }}">Équipements</a></nav>
<main>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash flash-{{ category }}" role="alert">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</body>
</html>
@@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block title %}Équipements{% endblock %}
{% block content %}
<h1>Inventaire des équipements</h1>
<section>
<h2>Ajouter un équipement</h2>
<form method="post" action="{{ url_for('create_equipment_form') }}">
<label>Hostname <input type="text" name="hostname" required></label>
<label>Type
<select name="type" required>
{% for t in types %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
</label>
<label>Adresse IP <input type="text" name="ip_address" required></label>
<label>Latence (ms) <input type="number" name="latency" step="0.1" min="0"></label>
<label><input type="checkbox" name="is_active" checked> Actif</label>
<fieldset>
<legend>Interfaces</legend>
<div class="interface-row">
<input type="number" name="ports[]" placeholder="Port" min="0" max="65535">
<input type="text" name="services[]" placeholder="Service">
</div>
<div class="interface-row">
<input type="number" name="ports[]" placeholder="Port" min="0" max="65535">
<input type="text" name="services[]" placeholder="Service">
</div>
</fieldset>
<button type="submit">Créer</button>
</form>
</section>
<section>
<h2>Équipements enregistrés ({{ equipments|length }})</h2>
{% if equipments %}
<table class="equipments-table">
<thead>
<tr>
<th>ID</th>
<th>Hostname</th>
<th>Type</th>
<th>Adresse IP</th>
<th>Latence</th>
<th>État</th>
<th>Interfaces</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for eq in equipments %}
<tr class="{% if not eq.is_active %}row-inactive{% else %}row-active{% endif %}">
<td>{{ eq.id }}</td>
<td>{{ eq.hostname }}</td>
<td><span class="badge">{{ eq.type }}</span></td>
<td>{{ eq.ip_address }}</td>
<td>{{ eq.latency if eq.latency is not none else '-' }} ms</td>
<td>
{% if eq.is_active %}
<span class="status status-active">Actif</span>
{% else %}
<span class="status status-inactive">Inactif</span>
{% endif %}
</td>
<td>
{% if eq.interfaces %}
<ul class="interfaces-list">
{% for i in eq.interfaces %}
<li>{{ i.service }} : {{ i.port }}/tcp</li>
{% endfor %}
</ul>
{% else %}
-
{% endif %}
</td>
<td class="actions">
<form method="post" action="{{ url_for('toggle_equipment', equipment_id=eq.id) }}">
<button type="submit" class="btn-toggle">
{% if eq.is_active %}Désactiver{% else %}Activer{% endif %}
</button>
</form>
<form method="post" action="{{ url_for('delete_equipment_form', equipment_id=eq.id) }}"
onsubmit="return confirm('Confirmer la suppression de {{ eq.hostname }} ?');">
<button type="submit" class="btn-delete">Supprimer</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">Aucun équipement enregistré.</p>
{% endif %}
</section>
{% endblock %}
+6
View File
@@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}Accueil{% endblock %}
{% block content %}
<h1>Bienvenue</h1>
<p><a href="{{ url_for('equipments_page') }}">Accéder à l'inventaire</a></p>
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -e
BASE="http://127.0.0.1:5000/api/equipments"
echo "=== GET liste ==="
curl -sf "$BASE" | python -m json.tool
echo "=== POST création ==="
NEW=$(curl -sf -X POST "$BASE" \
-H "Content-Type: application/json" \
-d '{"hostname":"bash-test","type":"switch","ip_address":"10.0.60.1","is_active":true}')
echo "$NEW" | python -m json.tool
ID=$(echo "$NEW" | python -c "import json,sys; print(json.load(sys.stdin)['id'])")
echo "=== GET #$ID ==="
curl -sf "$BASE/$ID" | python -m json.tool
echo "=== PATCH toggle #$ID ==="
curl -sf -X PATCH "$BASE/$ID/toggle" | python -m json.tool
echo "=== DELETE #$ID ==="
curl -sf -X DELETE "$BASE/$ID" -w "HTTP %{http_code}\n"
+304
View File
@@ -0,0 +1,304 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "3-4-api-rest"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "dotenv" },
{ name = "flask" },
{ name = "requests" },
{ name = "sqlalchemy" },
]
[package.metadata]
requires-dist = [
{ name = "dotenv", specifier = ">=0.9.9" },
{ name = "flask", specifier = ">=3.1.3" },
{ name = "requests", specifier = ">=2.34.2" },
{ name = "sqlalchemy", specifier = ">=2.0.51" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" },
{ url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" },
{ url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" },
{ url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" },
{ url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" },
{ url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" },
{ url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" },
{ url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" },
{ url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" },
{ url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" },
{ url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" },
{ url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" },
{ url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" },
{ url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" },
{ url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" },
{ url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" },
{ url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" },
{ url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" },
{ url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" },
{ url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" },
{ url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" },
{ url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" },
{ url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
]
[[package]]
name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
]
[[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 = "dotenv"
version = "0.9.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dotenv" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
]
[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]
[[package]]
name = "greenlet"
version = "3.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" },
{ url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" },
{ url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" },
{ url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" },
{ url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" },
{ url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" },
{ url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" },
{ url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" },
{ url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" },
{ url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" },
{ url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" },
{ url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" },
{ url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" },
{ url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" },
{ url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" },
{ url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" },
{ url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" },
{ url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" },
{ url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" },
{ url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" },
{ url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" },
{ url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" },
{ url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" },
{ url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" },
{ url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" },
{ url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" },
{ url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" },
{ url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" },
{ url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[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 = "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/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 = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.51"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" },
{ url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" },
{ url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" },
{ url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" },
{ url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" },
{ url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" },
{ url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" },
{ url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" },
{ url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" },
{ url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" },
{ url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" },
{ url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" },
]
[[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 = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
]
File diff suppressed because it is too large Load Diff