From bdbfa4e25add6c5dadc7393012483a045af70228 Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Wed, 28 Jan 2026 19:22:30 +0100 Subject: [PATCH] claude code --- .env.example | 62 + .gitignore | 119 ++ CLAUDE.md | 229 ++- Makefile | 133 ++ PROJECT_CONTEXT.md | 14 +- README.md | 19 +- a-lire.md | 30 +- amelioration.md | 18 + backend/CONTEXT.md | 43 +- backend/Dockerfile | 35 + backend/README.md | 71 + backend/alembic.ini | 69 + backend/alembic/env.py | 99 ++ backend/alembic/script.py.mako | 26 + ...0dd_initial_migration_create_all_tables.py | 107 ++ ...0128_1817_ee8035073398_add_url_to_items.py | 30 + backend/app/__init__.py | 0 backend/app/core/__init__.py | 0 backend/app/core/config.py | 121 ++ backend/app/core/database.py | 99 ++ backend/app/core/logging.py | 105 ++ backend/app/main.py | 126 ++ backend/app/models/__init__.py | 19 + backend/app/models/category.py | 61 + backend/app/models/document.py | 85 ++ backend/app/models/item.py | 113 ++ backend/app/models/location.py | 97 ++ backend/app/repositories/__init__.py | 15 + backend/app/repositories/base.py | 154 ++ backend/app/repositories/category.py | 85 ++ backend/app/repositories/document.py | 113 ++ backend/app/repositories/item.py | 247 ++++ backend/app/repositories/location.py | 171 +++ backend/app/routers/__init__.py | 11 + backend/app/routers/categories.py | 168 +++ backend/app/routers/items.py | 264 ++++ backend/app/routers/locations.py | 249 ++++ backend/app/schemas/__init__.py | 68 + backend/app/schemas/category.py | 48 + backend/app/schemas/common.py | 60 + backend/app/schemas/document.py | 63 + backend/app/schemas/item.py | 98 ++ backend/app/schemas/location.py | 70 + backend/app/services/__init__.py | 0 backend/app/utils/__init__.py | 0 backend/pyproject.toml | 142 ++ backend/tests/__init__.py | 0 backend/tests/integration/__init__.py | 0 backend/tests/unit/__init__.py | 0 backend/uv.lock | 1286 +++++++++++++++++ contracts/data_model.md | 34 +- data/.gitkeep | 0 docker-compose.yml | 48 + docs/ARCHITECTURE.md | 65 +- docs/STYLEGUIDE.md | 24 +- docs/WORKFLOW.md | 39 +- docs/adr/0001-choix-stack-technique.md | 192 +++ docs/adr/0001-exemple.md | 24 - .../0002-architecture-monolithe-modulaire.md | 235 +++ .../0003-recherche-full-text-sqlite-fts5.md | 259 ++++ .../0004-pas-authentification-reseau-local.md | 226 +++ frontend/CONTEXT.md | 39 +- frontend/Dockerfile | 23 + frontend/index.html | 14 + frontend/package.json | 58 + frontend/postcss.config.js | 6 + frontend/src/App.tsx | 588 ++++++++ frontend/src/api/categories.ts | 50 + frontend/src/api/client.ts | 41 + frontend/src/api/index.ts | 9 + frontend/src/api/items.ts | 83 ++ frontend/src/api/locations.ts | 91 ++ frontend/src/api/types.ts | 165 +++ .../components/categories/CategoryForm.tsx | 179 +++ frontend/src/components/categories/index.ts | 5 + frontend/src/components/common/Badge.tsx | 30 + .../src/components/common/ConfirmDialog.tsx | 66 + frontend/src/components/common/EmptyState.tsx | 30 + .../src/components/common/ErrorMessage.tsx | 43 + frontend/src/components/common/Icons.tsx | 65 + frontend/src/components/common/Loading.tsx | 25 + frontend/src/components/common/Modal.tsx | 75 + frontend/src/components/common/index.ts | 11 + frontend/src/components/items/ItemCard.tsx | 110 ++ frontend/src/components/items/ItemForm.tsx | 369 +++++ frontend/src/components/items/ItemList.tsx | 122 ++ frontend/src/components/items/index.ts | 7 + .../src/components/locations/LocationForm.tsx | 198 +++ frontend/src/components/locations/index.ts | 5 + frontend/src/hooks/index.ts | 7 + frontend/src/hooks/useCategories.ts | 80 + frontend/src/hooks/useItems.ts | 112 ++ frontend/src/hooks/useLocations.ts | 114 ++ frontend/src/main.tsx | 38 + frontend/src/styles/index.css | 182 +++ frontend/src/vite-env.d.ts | 12 + frontend/tailwind.config.js | 117 ++ frontend/tsconfig.json | 46 + frontend/tsconfig.node.json | 11 + frontend/vite.config.ts | 72 + outils_dev_pref.md | 38 +- product/BACKLOG.md | 104 +- product/VISION.md | 24 +- uploads/.gitkeep | 0 104 files changed, 9591 insertions(+), 261 deletions(-) create mode 100644 amelioration.md create mode 100644 backend/README.md create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/20260127_2122_8ba5962640dd_initial_migration_create_all_tables.py create mode 100644 backend/alembic/versions/20260128_1817_ee8035073398_add_url_to_items.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/database.py create mode 100644 backend/app/core/logging.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/category.py create mode 100644 backend/app/models/document.py create mode 100644 backend/app/models/item.py create mode 100644 backend/app/models/location.py create mode 100644 backend/app/repositories/__init__.py create mode 100644 backend/app/repositories/base.py create mode 100644 backend/app/repositories/category.py create mode 100644 backend/app/repositories/document.py create mode 100644 backend/app/repositories/item.py create mode 100644 backend/app/repositories/location.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/categories.py create mode 100644 backend/app/routers/items.py create mode 100644 backend/app/routers/locations.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/category.py create mode 100644 backend/app/schemas/common.py create mode 100644 backend/app/schemas/document.py create mode 100644 backend/app/schemas/item.py create mode 100644 backend/app/schemas/location.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/utils/__init__.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/integration/__init__.py create mode 100644 backend/tests/unit/__init__.py create mode 100644 backend/uv.lock create mode 100644 data/.gitkeep create mode 100644 docs/adr/0001-choix-stack-technique.md delete mode 100644 docs/adr/0001-exemple.md create mode 100644 docs/adr/0002-architecture-monolithe-modulaire.md create mode 100644 docs/adr/0003-recherche-full-text-sqlite-fts5.md create mode 100644 docs/adr/0004-pas-authentification-reseau-local.md create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/categories.ts create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/index.ts create mode 100644 frontend/src/api/items.ts create mode 100644 frontend/src/api/locations.ts create mode 100644 frontend/src/api/types.ts create mode 100644 frontend/src/components/categories/CategoryForm.tsx create mode 100644 frontend/src/components/categories/index.ts create mode 100644 frontend/src/components/common/Badge.tsx create mode 100644 frontend/src/components/common/ConfirmDialog.tsx create mode 100644 frontend/src/components/common/EmptyState.tsx create mode 100644 frontend/src/components/common/ErrorMessage.tsx create mode 100644 frontend/src/components/common/Icons.tsx create mode 100644 frontend/src/components/common/Loading.tsx create mode 100644 frontend/src/components/common/Modal.tsx create mode 100644 frontend/src/components/common/index.ts create mode 100644 frontend/src/components/items/ItemCard.tsx create mode 100644 frontend/src/components/items/ItemForm.tsx create mode 100644 frontend/src/components/items/ItemList.tsx create mode 100644 frontend/src/components/items/index.ts create mode 100644 frontend/src/components/locations/LocationForm.tsx create mode 100644 frontend/src/components/locations/index.ts create mode 100644 frontend/src/hooks/index.ts create mode 100644 frontend/src/hooks/useCategories.ts create mode 100644 frontend/src/hooks/useItems.ts create mode 100644 frontend/src/hooks/useLocations.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/styles/index.css create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 uploads/.gitkeep diff --git a/.env.example b/.env.example index e69de29..50dcc97 100644 --- a/.env.example +++ b/.env.example @@ -0,0 +1,62 @@ +# HomeStock - Variables d'environnement +# Copier ce fichier vers .env et ajuster les valeurs + +# === Backend Configuration === + +# Application +APP_NAME=HomeStock +APP_VERSION=0.1.0 +ENVIRONMENT=development # development | production +DEBUG=true +LOG_LEVEL=DEBUG # DEBUG | INFO | WARNING | ERROR + +# Server +BACKEND_HOST=0.0.0.0 +BACKEND_PORT=8000 +BACKEND_RELOAD=true # Hot reload en dev + +# Database +DATABASE_URL=sqlite:///./data/homestock.db +# Pour PostgreSQL (future migration) : +# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/homestock + +# CORS (Cross-Origin Resource Sharing) +CORS_ORIGINS=http://localhost:5173,http://10.0.0.50:5173 +CORS_ALLOW_CREDENTIALS=false + +# File Storage +UPLOAD_DIR=./uploads +MAX_UPLOAD_SIZE_MB=50 +ALLOWED_EXTENSIONS=jpg,jpeg,png,gif,pdf,doc,docx + +# Search (FTS5) +SEARCH_MIN_QUERY_LENGTH=2 + +# === Frontend Configuration === + +# API URL +VITE_API_BASE_URL=http://localhost:8000/api/v1 + +# App Config +VITE_APP_NAME=HomeStock +VITE_APP_VERSION=0.1.0 + +# === Docker Configuration === + +# Network +NETWORK_SUBNET=10.0.0.0/22 +BACKEND_IP=10.0.0.50 +FRONTEND_IP=10.0.0.51 + +# Ports +BACKEND_EXTERNAL_PORT=8000 +FRONTEND_EXTERNAL_PORT=5173 + +# === Development === + +# Python +PYTHONUNBUFFERED=1 +PYTHONDONTWRITEBYTECODE=1 + +# Node +NODE_ENV=development \ No newline at end of file diff --git a/.gitignore b/.gitignore index e69de29..1f107b1 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,119 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +*.manifest +*.spec +pip-log.txt +pip-delete-this-directory.txt +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ +.nox/ +.hypothesis/ +.mypy_cache/ +.dmypy.json +dmypy.json +.pyre/ +.pytype/ +cython_debug/ + +# Node.js / Frontend +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* +dist/ +dist-ssr/ +*.local + +# Environment variables +.env +.env.local +.env.*.local +.venv +venv/ +ENV/ +env/ +env.bak/ +venv.bak/ + +# IDE +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Database +*.db +*.sqlite +*.sqlite3 +data/*.db +data/*.sqlite + +# Uploads (fichiers utilisateur) +uploads/* +!uploads/.gitkeep +!uploads/photos/.gitkeep +!uploads/notices/.gitkeep +!uploads/factures/.gitkeep + +# Logs +*.log +logs/ +*.log.* + +# Docker +.dockerignore + +# OS +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.temp +.cache/ + +# Testing +.pytest_cache/ +coverage/ +.coverage + +# Build artifacts +*.pyc +*.pyo +*.pyd +.Python + +# Alembic +alembic/versions/*.pyc diff --git a/CLAUDE.md b/CLAUDE.md index 08eac03..a9bb935 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,37 +1,212 @@ -# Consignes strictes — Claude Code +# CLAUDE.md -Ces consignes sont obligatoires pour toute intervention avec Claude Code. +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. --- -## 1. Lecture préalable obligatoire -- Lire `a-lire.md`, `PROJECT_CONTEXT.md`, `outils_dev_pref.md`. -- Lire `docs/ARCHITECTURE.md` avant toute décision technique. +## Vue d'ensemble du projet -## 2. Gestion des zones -- Ne jamais supprimer ``. -- Ajouter un exemple guidant avec la mention "a supprimer". -- Utiliser `` pour les choix techniques. +**HomeStock** est une webapp de gestion d'inventaire domestique (style Homebox) permettant de cataloguer et gérer les équipements, matériels et consommables présents au domicile. -## 3. Documentation avant code -- Aucun code tant que `product/VISION.md` et `product/BACKLOG.md` ne sont pas complétés. -- Toute décision structurante → ajouter un ADR dans `docs/adr/`. +### Domaines couverts +- Bricolage +- Informatique +- Électronique +- Cuisine -## 4. Style d’écriture -- Français uniquement. -- Éviter les acronymes seuls : toujours expliquer (ex: "MFA = authentification multi‑facteurs"). -- Commentaires courts et utiles. - -## 5. Découpage des tâches -- Toute tâche doit être dans `tasks/`. -- Une tâche = un objectif clair + critères d’acceptation. -- Mentionner les fichiers autorisés. - -## 6. Qualité -- Indiquer les tests attendus même si non exécutés. -- Mettre à jour la documentation impactée. +### Fonctionnalités principales +- Catalogue d'objets avec caractéristiques détaillées +- Gestion des photos, notices et factures +- Suivi des prix et du stock +- Gestion de l'état (en utilisation / en stock) +- Localisation précise (lieu, meuble, tiroir, boîte) --- -## Exemple (a supprimer) -- "Ajouter ADR-0002 pour le choix de base de données." +## Architecture du projet + +### Structure globale +Le projet suit une architecture **monolithe modulaire** avec séparation frontend/backend : + +``` +/ +├── backend/ # API et logique métier +├── frontend/ # Interface utilisateur +├── contracts/ # Contrats d'API (OpenAPI) et modèles de données +├── docs/ # Documentation technique et ADR +├── product/ # Documentation produit (vision, backlog, roadmap) +├── tasks/ # Fichiers de tâches pour le découpage du travail +└── scripts/ # Scripts de développement et maintenance +``` + +### Fichiers de contexte clés +- [a-lire.md](a-lire.md) : Instructions d'initialisation du projet +- [PROJECT_CONTEXT.md](PROJECT_CONTEXT.md) : Contexte global et contraintes +- [outils_dev_pref.md](outils_dev_pref.md) : Environnement et préférences de développement +- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) : **À lire avant toute décision technique** +- [backend/CONTEXT.md](backend/CONTEXT.md) : Contexte spécifique au backend +- [frontend/CONTEXT.md](frontend/CONTEXT.md) : Contexte spécifique au frontend + +--- + +## Workflow de développement + +### 1. Documentation d'abord +**IMPORTANT** : Aucun code ne doit être écrit tant que la documentation n'est pas complétée : +- [product/VISION.md](product/VISION.md) doit être rempli +- [product/BACKLOG.md](product/BACKLOG.md) doit contenir les exigences (REQ-XXX) +- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) doit être lu et compris + +### 2. Gestion des zones à remplir +Le projet utilise un système de marqueurs pour identifier les sections à compléter : + +- `` : À compléter par l'utilisateur selon le contexte du projet + - **Ne jamais supprimer** ce marqueur sans le remplacer + - Ajouter un exemple guidant avec la mention "(exemple: ... — a supprimer)" + +- `` : À compléter par Claude Code lors des décisions techniques + - Remplacer par le contenu approprié + - Ajouter "complété par codex" en commentaire après remplissage + +### 3. Décisions architecturales (ADR) +Toute décision structurante doit être documentée dans un ADR (Architecture Decision Record) : +- Créer un nouveau fichier dans [docs/adr/](docs/adr/) +- Utiliser le template [docs/adr/TEMPLATE.md](docs/adr/TEMPLATE.md) +- Exemples : choix de base de données, framework, pattern d'authentification, etc. + +### 4. Découpage des tâches +- Toute tâche de développement doit avoir un fichier dans [tasks/](tasks/) +- Utiliser le template [tasks/TEMPLATE.md](tasks/TEMPLATE.md) +- Une tâche = un objectif clair + critères d'acceptation + fichiers concernés + +--- + +## Commandes de développement + +### Démarrage +```bash +make dev # Lancer l'environnement de développement +docker-compose up # Alternative avec Docker +``` + +### Tests et qualité +```bash +./scripts/test.sh # Exécuter les tests +./scripts/lint.sh # Linter le code +./scripts/fmt.sh # Formater le code +``` + +### Base de données +```bash +./scripts/db/backup.sh # Sauvegarder la base de données +./scripts/db/restore.sh # Restaurer une sauvegarde +``` + +**Note** : Ces scripts sont actuellement vides et doivent être complétés lors de l'implémentation. + +--- + +## Conventions de code + +### Langue +- **Français uniquement** pour toute la documentation et les commentaires +- Toujours expliquer les acronymes : "JWT = jeton d'authentification", "RGPD = Règlement Général sur la Protection des Données" +- Les noms de variables/fonctions peuvent être en anglais selon les conventions du langage + +### Style de documentation +- Commentaires courts et utiles uniquement +- Éviter les répétitions entre code et documentation +- Privilégier la clarté à la concision + +### Organisation du code +- Suivre l'organisation définie dans [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) +- Respecter les conventions du [docs/STYLEGUIDE.md](docs/STYLEGUIDE.md) +- Maintenir la séparation frontend/backend stricte + +--- + +## Contrats d'API + +Le répertoire [contracts/](contracts/) contient les spécifications d'API : +- [contracts/openapi.yaml](contracts/openapi.yaml) : Spécification OpenAPI +- [contracts/data_model.md](contracts/data_model.md) : Modèle de données +- [contracts/errors.md](contracts/errors.md) : Gestion des erreurs +- [contracts/pagination.md](contracts/pagination.md) : Convention de pagination +- [contracts/import/](contracts/import/) : Schémas d'import/export + +**Important** : Toute modification d'API doit mettre à jour ces contrats. + +--- + +## Environnement technique + +### Configuration +- OS : Debian 13 +- IDE : VS Code +- Conteneurs : Docker 29.1.5+ +- Git : Gitea (https://gitea.maison43.duckdns.org/) +- Timezone : Europe/Paris +- Réseau local : 10.0.0.0/22 + +### Stack technique (à finaliser) +- **Frontend** : À définir dans [frontend/CONTEXT.md](frontend/CONTEXT.md) +- **Backend** : Python ou Go (préférence indiquée dans [outils_dev_pref.md](outils_dev_pref.md)) +- **Base de données** : À définir via ADR +- **Stockage fichiers** : À définir (photos, notices, factures) + +### Thème UI +- Préférences : Gruvbox, Dark, Vintage - Monokai Dark +- À définir : couleurs dominantes, style UI, accessibilité + +--- + +## Processus de contribution + +### Pull Requests +- Consulter [docs/PR_CHECKLIST.md](docs/PR_CHECKLIST.md) avant de créer une PR +- Suivre le workflow défini dans [docs/WORKFLOW.md](docs/WORKFLOW.md) +- Une PR = une fonctionnalité / un fix + +### Qualité +- Indiquer les tests attendus même si non implémentés +- Mettre à jour toute documentation impactée +- Vérifier que les marqueurs `` ne sont pas supprimés par erreur + +### Sécurité +- Consulter [docs/SECURITY.md](docs/SECURITY.md) +- Pas de secrets en clair dans le code +- Respecter les contraintes RGPD si applicable + +--- + +## Ordre des opérations pour démarrer + +1. **Compléter la documentation de base** + - Remplir [PROJECT_CONTEXT.md](PROJECT_CONTEXT.md) + - Compléter [product/VISION.md](product/VISION.md) + - Définir les premières exigences dans [product/BACKLOG.md](product/BACKLOG.md) + +2. **Définir l'architecture technique** + - Compléter [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) + - Créer les ADR pour les choix techniques majeurs + - Finaliser [backend/CONTEXT.md](backend/CONTEXT.md) et [frontend/CONTEXT.md](frontend/CONTEXT.md) + +3. **Configurer l'environnement** + - Compléter [outils_dev_pref.md](outils_dev_pref.md) + - Configurer les scripts de développement + - Mettre en place Docker et docker-compose + +4. **Démarrer l'implémentation** + - Créer les tâches dans [tasks/](tasks/) + - Suivre le workflow défini + - Documenter les décisions au fur et à mesure + +--- + +## Références + +- **Documentation produit** : [product/](product/) +- **Documentation technique** : [docs/](docs/) +- **Tâches** : [tasks/](tasks/) +- **Opérations** : [docs/OPERATIONS.md](docs/OPERATIONS.md) +- **Déploiement** : [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) diff --git a/Makefile b/Makefile index e69de29..35f3272 100644 --- a/Makefile +++ b/Makefile @@ -0,0 +1,133 @@ +.PHONY: help dev test lint fmt clean docker-up docker-down install migrate + +# Variables +BACKEND_DIR = backend +FRONTEND_DIR = frontend +PYTHON = python3 +UV = uv +NPM = npm + +help: ## Affiche cette aide + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +# === Développement === + +dev: ## Lance l'environnement de développement complet (backend + frontend) + docker compose up --build + +dev-backend: ## Lance uniquement le backend en mode dev + cd $(BACKEND_DIR) && $(UV) run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +dev-frontend: ## Lance uniquement le frontend en mode dev + cd $(FRONTEND_DIR) && $(NPM) run dev + +# === Installation === + +install: install-backend install-frontend ## Installe toutes les dépendances + +install-backend: ## Installe les dépendances Python (backend) + cd $(BACKEND_DIR) && $(UV) sync + +install-frontend: ## Installe les dépendances Node (frontend) + cd $(FRONTEND_DIR) && $(NPM) install + +# === Tests === + +test: test-backend test-frontend ## Lance tous les tests + +test-backend: ## Lance les tests backend (pytest) + cd $(BACKEND_DIR) && $(UV) run pytest + +test-backend-cov: ## Lance les tests backend avec couverture + cd $(BACKEND_DIR) && $(UV) run pytest --cov=app --cov-report=html --cov-report=term + +test-frontend: ## Lance les tests frontend (vitest) + cd $(FRONTEND_DIR) && $(NPM) run test + +# === Linting & Formatage === + +lint: lint-backend lint-frontend ## Lance tous les linters + +lint-backend: ## Lint le code backend (ruff + mypy) + cd $(BACKEND_DIR) && $(UV) run ruff check . + cd $(BACKEND_DIR) && $(UV) run mypy . + +lint-frontend: ## Lint le code frontend (eslint) + cd $(FRONTEND_DIR) && $(NPM) run lint + +fmt: fmt-backend fmt-frontend ## Formate tout le code + +fmt-backend: ## Formate le code backend (ruff) + cd $(BACKEND_DIR) && $(UV) run ruff check --fix . + cd $(BACKEND_DIR) && $(UV) run ruff format . + +fmt-frontend: ## Formate le code frontend (prettier) + cd $(FRONTEND_DIR) && $(NPM) run fmt + +# === Base de données === + +migrate: ## Crée et applique une nouvelle migration Alembic + cd $(BACKEND_DIR) && $(UV) run alembic revision --autogenerate -m "$(msg)" + cd $(BACKEND_DIR) && $(UV) run alembic upgrade head + +migrate-up: ## Applique les migrations en attente + cd $(BACKEND_DIR) && $(UV) run alembic upgrade head + +migrate-down: ## Rollback de la dernière migration + cd $(BACKEND_DIR) && $(UV) run alembic downgrade -1 + +migrate-history: ## Affiche l'historique des migrations + cd $(BACKEND_DIR) && $(UV) run alembic history + +db-reset: ## Réinitialise la base de données (ATTENTION: supprime toutes les données!) + rm -f data/homestock.db + $(MAKE) migrate-up + +# === Docker === + +docker-up: ## Démarre les conteneurs Docker + docker compose up -d + +docker-down: ## Arrête les conteneurs Docker + docker compose down + +docker-logs: ## Affiche les logs des conteneurs + docker compose logs -f + +docker-rebuild: ## Rebuild et redémarre les conteneurs + docker compose up --build -d + +docker-clean: ## Nettoie les conteneurs, volumes et images Docker + docker compose down -v + docker system prune -f + +# === Nettoyage === + +clean: ## Nettoie les fichiers temporaires + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name "node_modules" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name "dist" -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete + rm -rf $(BACKEND_DIR)/htmlcov + rm -rf $(FRONTEND_DIR)/coverage + +clean-all: clean docker-clean ## Nettoie tout (fichiers + Docker) + rm -rf data/homestock.db + rm -rf uploads/* + +# === Utilitaires === + +shell-backend: ## Ouvre un shell Python dans l'environnement backend + cd $(BACKEND_DIR) && $(UV) run python + +shell-db: ## Ouvre un shell SQLite sur la base de données + sqlite3 data/homestock.db + +check: lint test ## Vérifie la qualité du code (lint + tests) + +init: install migrate-up ## Initialise le projet pour la première fois + @echo "✅ Projet initialisé avec succès!" + @echo "👉 Lancez 'make dev' pour démarrer le développement" diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index b74e7ea..cab6424 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -12,17 +12,17 @@ Il sert aux agents pour comprendre les objectifs et les contraintes. --- ## Résumé -- But du projet : (exemple: réduire les erreurs d'inventaire — a supprimer) -- Résultat attendu : (exemple: tableau de bord + alertes — a supprimer) +- But du projet : Remplacer la gestion manuelle et désorganisée de l'inventaire domestique par une solution structurée et interrogeable +- Résultat attendu : Application web permettant de retrouver rapidement n'importe quel objet, son emplacement exact, ses documents associés (factures, notices) et son état (stock, en utilisation) ## Parties prenantes -- Décideur : (exemple: Directeur des opérations — a supprimer) -- Utilisateurs finaux : (exemple: équipe logistique — a supprimer) +- Décideur : Utilisateur final (propriétaire du domicile) +- Utilisateurs finaux : Utilisateur unique (mono-utilisateur) avec possibilité d'évolution vers usage familial ## Contraintes -- Budget / délai : (exemple: 3 mois / 20k€ — a supprimer) -- Légalité / conformité : (exemple: RGPD — a supprimer) -- Tech imposée : (exemple: PostgreSQL — a supprimer) +- Budget / délai : Projet personnel, développement itératif sans contrainte de délai strict +- Légalité / conformité : Aucune contrainte légale (usage personnel, pas de données tierces) +- Tech imposée : Python (backend) et React (frontend) selon préférences, SQLite pour simplicité mono-utilisateur ## Références - Vision : `product/VISION.md` diff --git a/README.md b/README.md index bab1258..a57ae7c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# +# HomeStock -Résumé court : (exemple: à personnaliser — a supprimer) +Application web self-hosted de gestion d'inventaire domestique permettant de cataloguer équipements, matériels et consommables avec localisation précise et archivage de documents. --- @@ -11,12 +11,12 @@ Résumé court : (exemple: à personnaliser — a supprimer --- ## Objectif -- Problème : (exemple: suivi manuel sur tableur — a supprimer) -- Public cible : (exemple: PME logistique — a supprimer) +- Problème : Gestion désorganisée de l'inventaire domestique, difficulté à retrouver les objets, perte de notices/factures, absence de suivi des stocks +- Public cible : Particulier gérant son domicile, domaines bricolage/informatique/électronique/cuisine ## Démarrage rapide -- Prérequis : -- Lancer en local : +- Prérequis : Docker 29.1.5+, Docker Compose, ou Python 3.11+ + Node.js 20+ pour développement sans Docker +- Lancer en local : `docker-compose up` ou `make dev` (en développement : backend sur :8000, frontend sur :5173) ## Documentation - Contexte : `PROJECT_CONTEXT.md` @@ -25,9 +25,4 @@ Résumé court : (exemple: à personnaliser — a supprimer - Backlog : `product/BACKLOG.md` - Workflow : `docs/WORKFLOW.md` ---- - -## Exemple (a supprimer) -- Résumé : application web interne pour gérer des stocks. -- Problème : suivi manuel sur tableur. -- Lancer en local : `make dev`. +--- \ No newline at end of file diff --git a/a-lire.md b/a-lire.md index 4718e9c..d8af323 100644 --- a/a-lire.md +++ b/a-lire.md @@ -18,28 +18,28 @@ Ce dépôt est basé sur le template **template-webapp**. ## 1. Description du projet -- Nom du projet : (exemple: StockPilot — a supprimer) -- Type de webapp : (exemple: SaaS B2B — a supprimer) -- Public cible : (exemple: PME logistique — a supprimer) -- Objectif principal : (exemple: suivi des stocks en temps réel — a supprimer) +- Nom du projet : HomeStock +- Type de webapp : Application web self-hosted mono-utilisateur +- Public cible : Particulier gérant son inventaire domestique +- Objectif principal : Cataloguer et gérer l'inventaire complet de la maison (équipements, matériels, consommables) avec suivi précis des emplacements, documents et états --- ## 2. Contraintes fortes -- Self-hosted / Cloud : (exemple: Self-hosted — a supprimer) -- Mono-utilisateur / Multi-utilisateur : (exemple: Multi-utilisateur — a supprimer) -- Données sensibles : oui / non -- Contraintes légales (RGPD = Règlement Général sur la Protection des Données, etc.) : (exemple: RGPD + hébergement UE — a supprimer) +- Self-hosted / Cloud : Self-hosted (déploiement local sur le réseau domestique) +- Mono-utilisateur / Multi-utilisateur : Mono-utilisateur +- Données sensibles : Non (données personnelles mais non sensibles : inventaire domestique, factures) +- Contraintes légales : Aucune contrainte légale stricte (usage personnel, pas de traitement de données tierces) --- ## 3. Stack envisagée (indicative) -- Frontend : -- Backend : -- Base de données : -- Stockage fichiers : +- Frontend : React 18+ avec Vite, TanStack Query (React Query), React Router, TailwindCSS +- Backend : Python 3.11+ avec FastAPI, Pydantic pour validation, SQLAlchemy comme ORM +- Base de données : SQLite (fichier local, adapté au mono-utilisateur) +- Stockage fichiers : Système de fichiers local (dossier uploads/ avec organisation par type) --- @@ -59,8 +59,4 @@ Ce dépôt est basé sur le template **template-webapp**. - Créer les premières REQ (exigences) dans `product/BACKLOG.md` - Documenter les outils dans `outils_dev_pref.md` ---- - -## Exemple (a supprimer) -- Type de webapp : SaaS B2B. -- Contraintes : RGPD + hébergement UE. +--- \ No newline at end of file diff --git a/amelioration.md b/amelioration.md new file mode 100644 index 0000000..a84bf72 --- /dev/null +++ b/amelioration.md @@ -0,0 +1,18 @@ +- [ ] ajout d'un bouton setting qui donne acces aux parametres du frontend et de backend (separer le 2) +- [ ] ajout du theme gruvbox dark vintage +- [ ] ajout style icon material design ou fa +- [ ] ajout image et thumbnail dans item +- [ ] ajout notice pdf dans item +- [ ] ajout url dans item +- [ ] ajout caracteristique dans item +- [ ] ajout status integre dans item +- [ ] ajout boutique pdf dans item +- [ ] ajout menu people pour gerer les pret +- [ ] ajout people si pret selectionné pdf dans item +- [ ] popup ajout item plus large +- [ ] app responsive avec mode laptop et smartphone +- [ ] si status integre selectionné, on peut selectionner un objet parent, ex une carte pci express est integrer dans un desktop +- [ ] ajout composant dans item il peut etre assimilié a enfant mais et adaptable: ex un pc desktop peut avoir 3 emplacement pcie, 4 emplcement sata, 5 usb, 4 memoire, ... (brainstorming) +- [ ] import fichier json depuis un store ( status non assigné) + + diff --git a/backend/CONTEXT.md b/backend/CONTEXT.md index 2b7b8ed..bb0e2e0 100644 --- a/backend/CONTEXT.md +++ b/backend/CONTEXT.md @@ -12,38 +12,33 @@ Tout ce qui est indiqué ici est la référence pour les agents backend. --- ## Objectif du backend -- Problème métier couvert : (exemple: suivi manuel sur tableur — a supprimer) -- Responsabilités principales : -- Hors périmètre : (exemple: à personnaliser — a supprimer) +- Problème métier couvert : Centraliser et structurer les données d'inventaire domestique avec recherche efficace et gestion de fichiers +- Responsabilités principales : API REST CRUD (items, locations, categories), upload/stockage fichiers, recherche full-text, validation données, génération OpenAPI +- Hors périmètre : Rendu frontend (SPA indépendante), authentification complexe (MVP mono-utilisateur), analytics avancées ## Interfaces -- API publique (API = Interface de Programmation) : -- Authentification/autorisation : -- Intégrations externes : (exemple: ERP existant — a supprimer) +- API publique : REST JSON à `/api/v1/`, OpenAPI 3.0 auto-générée à `/docs`, endpoints principaux = items, locations, categories, documents, search +- Authentification/autorisation : Optionnelle pour MVP (déploiement local), si activée = session cookie basique, pas de rôles complexes (mono-utilisateur) +- Intégrations externes : Aucune intégration externe, système autonome ## Données -- Base(s) utilisée(s) : -- Modèle de données clé : -- Stratégie de migration : +- Base(s) utilisée(s) : SQLite (fichier homestock.db) avec extension FTS5 pour recherche full-text +- Modèle de données clé : Item (objet principal), Location (hiérarchie lieu/meuble/tiroir), Category (domaine), Document (fichier), relations Many-to-One et Many-to-Many +- Stratégie de migration : Alembic pour migrations versionnées, auto-génération à partir des modèles SQLAlchemy, migrations réversibles up/down ## Architecture interne -- Style (monolithe modulaire, hexagonal, etc.) : -- Modules principaux : -- Couche d’accès aux données : +- Style : Monolithe modulaire avec séparation claire routers → services → repositories (3-layer architecture) +- Modules principaux : `routers/` (endpoints FastAPI), `services/` (logique métier), `models/` (ORM SQLAlchemy), `schemas/` (Pydantic validation), `repositories/` (accès BDD) +- Couche d'accès aux données : Repository pattern avec SQLAlchemy async sessions, abstraction BDD pour faciliter tests et évolution ## Qualité & exploitation -- Observabilité (logs/metrics/traces = journaux/mesures/traces) : -- Tests (unitaires/intégration) : -- Performance attendue : (exemple: à personnaliser — a supprimer) +- Observabilité : Logs structurés avec loguru (format JSON), endpoint `/health` pour healthcheck, pas de tracing distribué (monolithe) +- Tests : pytest avec tests unitaires (services/), tests intégration (routers/ avec TestClient), fixtures pour BDD test, couverture 70%+ sur logique métier +- Performance attendue : Réponse API <200ms pour GET simple, <500ms pour recherche full-text, upload fichiers <5s pour 10MB, pas de contrainte scalabilité (mono-utilisateur) ## Conventions -- Organisation du code : -- Nommage : -- Gestion erreurs : +- Organisation du code : `backend/app/` racine, sous-dossiers par responsabilité (routers/, services/, models/, schemas/, repositories/), un fichier par entité +- Nommage : snake_case pour tout (variables, fonctions, fichiers), préfixes get_/create_/update_/delete_ pour CRUD, suffixes _service/_repository selon couche +- Gestion erreurs : HTTPException FastAPI pour erreurs API avec codes standards (400/404/500), exceptions métier custom héritant de BaseException, logging erreurs avec contexte ---- - -## Exemple (a supprimer) -- Style : monolithe modulaire avec modules `users`, `billing`, `catalog`. -- API : REST `/api/v1` + JWT (Jeton d’authentification). -- DB : PostgreSQL, migrations via outils natifs. +--- \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index e69de29..9b1bc12 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +# Image de base Python 3.11 +FROM python:3.11-slim + +# Variables d'environnement Python +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Installer uv (modern Python package manager) +RUN pip install uv + +# Répertoire de travail +WORKDIR /app + +# Copier les fichiers de dépendances +COPY pyproject.toml README.md ./ + +# Installer les dépendances (prod + dev pour hot-reload) +RUN uv sync + +# Copier le code source +COPY . . + +# Créer les répertoires nécessaires +RUN mkdir -p /app/data /app/uploads + +# Exposer le port FastAPI +EXPOSE 8000 + +# Commande par défaut (peut être overridée par docker-compose) +# Note: --reload est activé pour le développement +CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..e3f7d8a --- /dev/null +++ b/backend/README.md @@ -0,0 +1,71 @@ +# HomeStock Backend + +Backend API pour la gestion d'inventaire domestique. + +## Stack technique + +- **FastAPI** : Framework web moderne et performant +- **SQLAlchemy 2.0+** : ORM avec support asynchrone +- **SQLite** : Base de données embarquée +- **Alembic** : Migrations de base de données +- **Pydantic** : Validation des données +- **Loguru** : Logging avancé + +## Installation + +```bash +# Avec uv (recommandé) +uv sync + +# Ou avec pip +pip install -e . +``` + +## Lancement + +```bash +# Mode développement (avec hot-reload) +uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +# Ou via le Makefile depuis la racine +make dev-backend +``` + +## Tests + +```bash +# Lancer les tests +uv run pytest + +# Avec couverture +uv run pytest --cov=app --cov-report=html +``` + +## Migrations + +```bash +# Créer une nouvelle migration +uv run alembic revision --autogenerate -m "Description" + +# Appliquer les migrations +uv run alembic upgrade head + +# Rollback +uv run alembic downgrade -1 +``` + +## Structure + +``` +backend/ +├── app/ +│ ├── core/ # Configuration, database, logging +│ ├── models/ # Modèles SQLAlchemy +│ ├── schemas/ # Schémas Pydantic +│ ├── routers/ # Endpoints API +│ ├── services/ # Logique métier +│ ├── repositories/ # Accès données +│ └── utils/ # Utilitaires +├── alembic/ # Migrations +└── tests/ # Tests +``` diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..7c87f0a --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,69 @@ +# Configuration Alembic pour les migrations de base de données +# Documentation : https://alembic.sqlalchemy.org/en/latest/tutorial.html + +[alembic] +# Chemin vers le dossier des migrations +script_location = alembic + +# Template utilisé pour générer les fichiers de migration +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# prepend_sys_path = . + +# Fuseau horaire pour les timestamps des migrations +# timezone = UTC + +# Longueur maximale des caractères pour le slug du nom de fichier de migration +# truncate_slug_length = 40 + +# Nom de la branche principale (pour multi-branches) +# version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# URL de connexion à la base de données +# Note : Cette valeur sera overridée par env.py qui lit depuis config.py +sqlalchemy.url = sqlite:///./data/homestock.db + +# Encode pour les migrations Python +# output_encoding = utf-8 + +[post_write_hooks] +# Hook pour formatter automatiquement les migrations avec ruff +hooks = ruff +ruff.type = console_scripts +ruff.entrypoint = ruff +ruff.options = format REVISION_SCRIPT_FILENAME + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..3b79a0c --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,99 @@ +"""Environnement Alembic pour les migrations de base de données. + +Ce fichier configure et exécute les migrations SQLAlchemy avec Alembic. +Il supporte les migrations synchrones et asynchrones. +""" + +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +# Import de la configuration et des modèles +from app.core.config import settings +from app.core.database import Base + +# Import explicite de tous les modèles pour autogenerate +import app.models # noqa: F401 + +# Configuration Alembic +config = context.config + +# Interpréter le fichier de configuration pour le logging Python +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Métadonnées des modèles pour autogenerate +target_metadata = Base.metadata + +# Override de l'URL de connexion depuis settings +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + + +def run_migrations_offline() -> None: + """Exécute les migrations en mode 'offline'. + + Configure le contexte avec uniquement une URL sans créer d'Engine. + Les commandes SQL sont émises vers un fichier script au lieu d'être + exécutées directement sur la base de données. + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, # Détecte les changements de types + compare_server_default=True, # Détecte les changements de valeurs par défaut + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + """Exécute les migrations avec une connexion donnée.""" + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Exécute les migrations en mode asynchrone. + + Crée un moteur asynchrone et exécute les migrations. + """ + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Exécute les migrations en mode 'online'. + + Crée un Engine et associe une connexion au contexte. + """ + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..edd9c1b --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + """Applique la migration (passage à la version suivante).""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Annule la migration (retour à la version précédente).""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/20260127_2122_8ba5962640dd_initial_migration_create_all_tables.py b/backend/alembic/versions/20260127_2122_8ba5962640dd_initial_migration_create_all_tables.py new file mode 100644 index 0000000..67ed6b0 --- /dev/null +++ b/backend/alembic/versions/20260127_2122_8ba5962640dd_initial_migration_create_all_tables.py @@ -0,0 +1,107 @@ +"""Initial migration: create all tables + +Revision ID: 8ba5962640dd +Revises: +Create Date: 2026-01-27 21:22:27.022127 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8ba5962640dd' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Applique la migration (passage à la version suivante).""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('categories', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=7), nullable=True), + sa.Column('icon', sa.String(length=50), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=True) + op.create_table('locations', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('type', sa.Enum('ROOM', 'FURNITURE', 'DRAWER', 'BOX', name='locationtype', native_enum=False, length=20), nullable=False), + sa.Column('parent_id', sa.Integer(), nullable=True), + sa.Column('path', sa.String(length=500), nullable=False), + sa.Column('description', sa.String(length=500), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['parent_id'], ['locations.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_locations_name'), 'locations', ['name'], unique=False) + op.create_index(op.f('ix_locations_parent_id'), 'locations', ['parent_id'], unique=False) + op.create_index(op.f('ix_locations_path'), 'locations', ['path'], unique=False) + op.create_table('items', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('quantity', sa.Integer(), nullable=False), + sa.Column('status', sa.Enum('IN_STOCK', 'IN_USE', 'BROKEN', 'SOLD', 'LENT', name='itemstatus', native_enum=False, length=20), nullable=False), + sa.Column('brand', sa.String(length=100), nullable=True), + sa.Column('model', sa.String(length=100), nullable=True), + sa.Column('serial_number', sa.String(length=100), nullable=True), + sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('purchase_date', sa.Date(), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('category_id', sa.Integer(), nullable=False), + sa.Column('location_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['location_id'], ['locations.id'], ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('serial_number') + ) + op.create_index(op.f('ix_items_category_id'), 'items', ['category_id'], unique=False) + op.create_index(op.f('ix_items_location_id'), 'items', ['location_id'], unique=False) + op.create_index(op.f('ix_items_name'), 'items', ['name'], unique=False) + op.create_table('documents', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('filename', sa.String(length=255), nullable=False), + sa.Column('original_name', sa.String(length=255), nullable=False), + sa.Column('type', sa.Enum('PHOTO', 'MANUAL', 'INVOICE', 'WARRANTY', 'OTHER', name='documenttype', native_enum=False, length=20), nullable=False), + sa.Column('mime_type', sa.String(length=100), nullable=False), + sa.Column('size_bytes', sa.Integer(), nullable=False), + sa.Column('file_path', sa.String(length=500), nullable=False), + sa.Column('description', sa.String(length=500), nullable=True), + sa.Column('item_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['item_id'], ['items.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('filename') + ) + op.create_index(op.f('ix_documents_item_id'), 'documents', ['item_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Annule la migration (retour à la version précédente).""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_documents_item_id'), table_name='documents') + op.drop_table('documents') + op.drop_index(op.f('ix_items_name'), table_name='items') + op.drop_index(op.f('ix_items_location_id'), table_name='items') + op.drop_index(op.f('ix_items_category_id'), table_name='items') + op.drop_table('items') + op.drop_index(op.f('ix_locations_path'), table_name='locations') + op.drop_index(op.f('ix_locations_parent_id'), table_name='locations') + op.drop_index(op.f('ix_locations_name'), table_name='locations') + op.drop_table('locations') + op.drop_index(op.f('ix_categories_name'), table_name='categories') + op.drop_table('categories') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/20260128_1817_ee8035073398_add_url_to_items.py b/backend/alembic/versions/20260128_1817_ee8035073398_add_url_to_items.py new file mode 100644 index 0000000..41fd817 --- /dev/null +++ b/backend/alembic/versions/20260128_1817_ee8035073398_add_url_to_items.py @@ -0,0 +1,30 @@ +"""add_url_to_items + +Revision ID: ee8035073398 +Revises: 8ba5962640dd +Create Date: 2026-01-28 18:17:51.225223 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ee8035073398' +down_revision = '8ba5962640dd' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Applique la migration (passage à la version suivante).""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('items', sa.Column('url', sa.String(length=500), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Annule la migration (retour à la version précédente).""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('items', 'url') + # ### end Alembic commands ### diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..c723949 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,121 @@ +"""Configuration de l'application HomeStock. + +Utilise Pydantic Settings pour charger et valider les variables d'environnement. +Documentation : https://docs.pydantic.dev/latest/concepts/pydantic_settings/ +""" + +from functools import lru_cache +from typing import Literal + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Configuration globale de l'application. + + Les valeurs sont chargées depuis les variables d'environnement ou le fichier .env. + """ + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # === Application === + APP_NAME: str = Field(default="HomeStock", description="Nom de l'application") + APP_VERSION: str = Field(default="0.1.0", description="Version de l'application") + ENVIRONMENT: Literal["development", "production"] = Field( + default="development", description="Environnement d'exécution" + ) + DEBUG: bool = Field(default=True, description="Mode debug") + LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field( + default="DEBUG", description="Niveau de log" + ) + + # === Serveur === + BACKEND_HOST: str = Field(default="0.0.0.0", description="Host du serveur backend") + BACKEND_PORT: int = Field(default=8000, description="Port du serveur backend") + BACKEND_RELOAD: bool = Field( + default=True, description="Hot reload en développement" + ) + + # === Base de données === + DATABASE_URL: str = Field( + default="sqlite+aiosqlite:///./data/homestock.db", + description="URL de connexion à la base de données", + ) + + @field_validator("DATABASE_URL") + @classmethod + def validate_database_url(cls, v: str) -> str: + """Valide et normalise l'URL de la base de données.""" + # Pour SQLite, s'assurer que le driver async est utilisé + if v.startswith("sqlite:///"): + return v.replace("sqlite:///", "sqlite+aiosqlite:///") + return v + + # === CORS === + CORS_ORIGINS: str = Field( + default="http://localhost:5173,http://10.0.0.50:5173", + description="Origines autorisées pour CORS (séparées par des virgules)", + ) + CORS_ALLOW_CREDENTIALS: bool = Field( + default=False, description="Autorise les credentials CORS" + ) + + @property + def cors_origins_list(self) -> list[str]: + """Retourne la liste des origines CORS autorisées.""" + return [origin.strip() for origin in self.CORS_ORIGINS.split(",")] + + # === Stockage fichiers === + UPLOAD_DIR: str = Field(default="./uploads", description="Répertoire des uploads") + MAX_UPLOAD_SIZE_MB: int = Field( + default=50, description="Taille max des uploads en Mo" + ) + ALLOWED_EXTENSIONS: str = Field( + default="jpg,jpeg,png,gif,pdf,doc,docx", + description="Extensions de fichiers autorisées (séparées par des virgules)", + ) + + @property + def allowed_extensions_list(self) -> list[str]: + """Retourne la liste des extensions autorisées.""" + return [ext.strip().lower() for ext in self.ALLOWED_EXTENSIONS.split(",")] + + @property + def max_upload_size_bytes(self) -> int: + """Retourne la taille max en octets.""" + return self.MAX_UPLOAD_SIZE_MB * 1024 * 1024 + + # === Recherche === + SEARCH_MIN_QUERY_LENGTH: int = Field( + default=2, description="Longueur minimale des requêtes de recherche" + ) + + # === Propriétés calculées === + @property + def is_development(self) -> bool: + """Indique si l'environnement est en développement.""" + return self.ENVIRONMENT == "development" + + @property + def is_production(self) -> bool: + """Indique si l'environnement est en production.""" + return self.ENVIRONMENT == "production" + + +@lru_cache +def get_settings() -> Settings: + """Retourne l'instance singleton de la configuration. + + Utilise lru_cache pour ne charger la configuration qu'une seule fois. + """ + return Settings() + + +# Instance globale de configuration +settings = get_settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..02726f6 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,99 @@ +"""Configuration de la base de données avec SQLAlchemy. + +Utilise SQLAlchemy 2.0+ avec support asynchrone (aiosqlite pour SQLite). +Documentation : https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html +""" + +from collections.abc import AsyncGenerator +from typing import Any + +from sqlalchemy import event +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase + +from app.core.config import settings + + +class Base(DeclarativeBase): + """Classe de base pour tous les modèles SQLAlchemy.""" + + pass + + +# Création du moteur asynchrone +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, # Log SQL en mode debug + future=True, + # Pool de connexions pour SQLite + pool_pre_ping=True, # Vérifie la connexion avant utilisation +) + + +# Configuration spécifique pour SQLite (activation des foreign keys) +@event.listens_for(engine.sync_engine, "connect") +def set_sqlite_pragma(dbapi_conn: Any, connection_record: Any) -> None: + """Active les contraintes de clés étrangères pour SQLite. + + SQLite désactive par défaut les foreign keys, il faut les activer manuellement. + """ + cursor = dbapi_conn.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +# Session factory pour créer des sessions asynchrones +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, # Ne pas expirer les objets après commit + autocommit=False, + autoflush=False, +) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """Générateur de session de base de données pour FastAPI. + + Utilisé comme dépendance FastAPI pour injecter une session dans les routes. + + Usage: + @router.get("/items") + async def get_items(db: AsyncSession = Depends(get_db)): + ... + + Yields: + AsyncSession: Session SQLAlchemy asynchrone + """ + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def init_db() -> None: + """Initialise la base de données. + + Crée toutes les tables définies dans les modèles. + À utiliser uniquement en développement ou pour les tests. + En production, utiliser Alembic pour les migrations. + """ + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def close_db() -> None: + """Ferme proprement les connexions à la base de données. + + À appeler lors de l'arrêt de l'application. + """ + await engine.dispose() diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..874dc19 --- /dev/null +++ b/backend/app/core/logging.py @@ -0,0 +1,105 @@ +"""Configuration du système de logging avec Loguru. + +Loguru est une bibliothèque de logging moderne et simple à utiliser. +Documentation : https://loguru.readthedocs.io/ +""" + +import sys +from pathlib import Path + +from loguru import logger + +from app.core.config import settings + +# Supprimer les handlers par défaut de loguru +logger.remove() + + +def setup_logging() -> None: + """Configure le système de logging pour l'application. + + - En développement : logs dans stdout + fichier debug + - En production : logs dans fichiers avec rotation + """ + # Format de log avec couleurs pour stdout + log_format = ( + "{time:YYYY-MM-DD HH:mm:ss.SSS} | " + "{level: <8} | " + "{name}:{function}:{line} - " + "{message}" + ) + + # Format de log sans couleurs pour fichiers + log_format_file = ( + "{time:YYYY-MM-DD HH:mm:ss.SSS} | " + "{level: <8} | " + "{name}:{function}:{line} - " + "{message}" + ) + + # === Configuration commune === + # Handler pour stdout (console) + logger.add( + sys.stdout, + format=log_format, + level=settings.LOG_LEVEL, + colorize=True, + backtrace=True, # Affiche le traceback complet des exceptions + diagnose=settings.DEBUG, # Affiche les valeurs des variables en debug + ) + + # Créer le répertoire des logs s'il n'existe pas + log_dir = Path("logs") + log_dir.mkdir(exist_ok=True) + + # === Configuration par environnement === + if settings.is_development: + # En développement : logs détaillés dans un fichier + logger.add( + "logs/homestock_dev.log", + format=log_format_file, + level="DEBUG", + rotation="10 MB", # Rotation tous les 10 Mo + retention="7 days", # Garde les logs 7 jours + compression="zip", # Compression des logs archivés + backtrace=True, + diagnose=True, + ) + else: + # En production : logs normaux + logs d'erreurs séparés + logger.add( + "logs/homestock.log", + format=log_format_file, + level="INFO", + rotation="50 MB", + retention="30 days", + compression="zip", + backtrace=True, + diagnose=False, + ) + + # Fichier séparé pour les erreurs + logger.add( + "logs/homestock_errors.log", + format=log_format_file, + level="ERROR", + rotation="50 MB", + retention="90 days", # Garde les erreurs plus longtemps + compression="zip", + backtrace=True, + diagnose=False, + ) + + logger.info(f"Logging configuré (level={settings.LOG_LEVEL}, env={settings.ENVIRONMENT})") + + +def get_logger(name: str) -> "logger": + """Retourne un logger avec un nom spécifique. + + Args: + name: Nom du logger (généralement __name__ du module) + + Returns: + Logger configuré avec le nom spécifié + """ + return logger.bind(name=name) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..887c571 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,126 @@ +"""Point d'entrée de l'application HomeStock. + +Application FastAPI pour la gestion d'inventaire domestique. +""" + +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from loguru import logger + +from app.core.config import settings +from app.core.database import close_db, init_db +from app.core.logging import setup_logging + +# Configuration du logging +setup_logging() + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> Any: + """Gère le cycle de vie de l'application. + + - Startup : Initialise la base de données + - Shutdown : Ferme les connexions proprement + """ + logger.info("Démarrage de l'application HomeStock") + logger.info(f"Environnement: {settings.ENVIRONMENT}") + logger.info(f"Version: {settings.APP_VERSION}") + + # Initialisation de la base de données + # Note: En production, utiliser Alembic pour les migrations + if settings.is_development: + logger.debug("Initialisation de la base de données (mode développement)") + await init_db() + + yield + + # Nettoyage + logger.info("Arrêt de l'application HomeStock") + await close_db() + + +# Création de l'application FastAPI +app = FastAPI( + title=settings.APP_NAME, + description="API pour la gestion d'inventaire domestique", + version=settings.APP_VERSION, + docs_url="/api/docs" if settings.is_development else None, # Swagger UI + redoc_url="/api/redoc" if settings.is_development else None, # ReDoc + openapi_url="/api/openapi.json" if settings.is_development else None, + lifespan=lifespan, +) + + +# === Configuration CORS === +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=settings.CORS_ALLOW_CREDENTIALS, + allow_methods=["*"], # Autorise toutes les méthodes (GET, POST, PUT, DELETE, etc.) + allow_headers=["*"], # Autorise tous les headers +) + + +# === Routes de santé === +@app.get("/", tags=["Health"]) +async def root() -> dict[str, str]: + """Route racine - Vérification de l'état de l'API.""" + return { + "app": settings.APP_NAME, + "version": settings.APP_VERSION, + "status": "running", + } + + +@app.get("/health", tags=["Health"]) +async def health() -> dict[str, str]: + """Endpoint de santé pour monitoring.""" + return { + "status": "healthy", + "environment": settings.ENVIRONMENT, + } + + +# === Gestion globale des erreurs === +@app.exception_handler(Exception) +async def global_exception_handler(request: Any, exc: Exception) -> JSONResponse: + """Gestionnaire global des exceptions non capturées. + + Log l'erreur et retourne une réponse JSON standardisée. + """ + logger.error(f"Erreur non gérée: {exc}", exc_info=True) + + # En production, masquer les détails de l'erreur + detail = str(exc) if settings.is_development else "Erreur interne du serveur" + + return JSONResponse( + status_code=500, + content={ + "detail": detail, + "type": "internal_server_error", + }, + ) + + +# === Enregistrement des routers === +from app.routers import categories_router, items_router, locations_router + +app.include_router(categories_router, prefix="/api/v1") +app.include_router(locations_router, prefix="/api/v1") +app.include_router(items_router, prefix="/api/v1") + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "app.main:app", + host=settings.BACKEND_HOST, + port=settings.BACKEND_PORT, + reload=settings.BACKEND_RELOAD, + log_level=settings.LOG_LEVEL.lower(), + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..d5b4996 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,19 @@ +"""Package des modèles SQLAlchemy. + +Importe tous les modèles pour qu'ils soient disponibles pour Alembic. +""" + +from app.models.category import Category +from app.models.document import Document, DocumentType +from app.models.item import Item, ItemStatus +from app.models.location import Location, LocationType + +__all__ = [ + "Category", + "Location", + "LocationType", + "Item", + "ItemStatus", + "Document", + "DocumentType", +] diff --git a/backend/app/models/category.py b/backend/app/models/category.py new file mode 100644 index 0000000..679a29e --- /dev/null +++ b/backend/app/models/category.py @@ -0,0 +1,61 @@ +"""Modèle SQLAlchemy pour les catégories d'objets. + +Les catégories permettent de classer les objets par domaine d'utilisation +(bricolage, informatique, électronique, cuisine, etc.). +""" + +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.item import Item + + +class Category(Base): + """Catégorie d'objets. + + Attributes: + id: Identifiant unique auto-incrémenté + name: Nom de la catégorie (ex: "Bricolage", "Informatique") + description: Description optionnelle de la catégorie + color: Couleur hexadécimale pour l'affichage (ex: "#3b82f6") + icon: Nom d'icône optionnel (ex: "wrench", "computer") + created_at: Date/heure de création (auto) + updated_at: Date/heure de dernière modification (auto) + items: Relation vers les objets de cette catégorie + """ + + __tablename__ = "categories" + + # Colonnes + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + color: Mapped[str | None] = mapped_column(String(7), nullable=True) # Format: #RRGGBB + icon: Mapped[str | None] = mapped_column(String(50), nullable=True) + + # Timestamps + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relations + items: Mapped[list["Item"]] = relationship( + "Item", back_populates="category", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + """Représentation string de la catégorie.""" + return f"" diff --git a/backend/app/models/document.py b/backend/app/models/document.py new file mode 100644 index 0000000..1b2f260 --- /dev/null +++ b/backend/app/models/document.py @@ -0,0 +1,85 @@ +"""Modèle SQLAlchemy pour les documents attachés aux objets. + +Les documents peuvent être des photos, notices d'utilisation, factures, etc. +Ils sont stockés sur le système de fichiers local. +""" + +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.item import Item + +import enum + + +class DocumentType(str, enum.Enum): + """Type de document.""" + + PHOTO = "photo" # Photo de l'objet + MANUAL = "manual" # Notice d'utilisation + INVOICE = "invoice" # Facture d'achat + WARRANTY = "warranty" # Garantie + OTHER = "other" # Autre + + +class Document(Base): + """Document attaché à un objet. + + Attributes: + id: Identifiant unique auto-incrémenté + filename: Nom du fichier sur le disque (UUID + extension) + original_name: Nom original du fichier uploadé + type: Type de document (photo/manual/invoice/warranty/other) + mime_type: Type MIME (ex: "image/jpeg", "application/pdf") + size_bytes: Taille du fichier en octets + file_path: Chemin relatif du fichier (ex: "uploads/photos/uuid.jpg") + description: Description optionnelle + item_id: ID de l'objet associé (FK) + created_at: Date/heure de création (auto) + updated_at: Date/heure de dernière modification (auto) + item: Relation vers l'objet + """ + + __tablename__ = "documents" + + # Colonnes + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + filename: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + original_name: Mapped[str] = mapped_column(String(255), nullable=False) + type: Mapped[DocumentType] = mapped_column( + Enum(DocumentType, native_enum=False, length=20), nullable=False + ) + mime_type: Mapped[str] = mapped_column(String(100), nullable=False) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + file_path: Mapped[str] = mapped_column(String(500), nullable=False) + description: Mapped[str | None] = mapped_column(String(500), nullable=True) + + # Foreign Key + item_id: Mapped[int] = mapped_column( + Integer, ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True + ) + + # Timestamps + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relations + item: Mapped["Item"] = relationship("Item", back_populates="documents") + + def __repr__(self) -> str: + """Représentation string du document.""" + return f"" diff --git a/backend/app/models/item.py b/backend/app/models/item.py new file mode 100644 index 0000000..6362835 --- /dev/null +++ b/backend/app/models/item.py @@ -0,0 +1,113 @@ +"""Modèle SQLAlchemy pour les objets de l'inventaire. + +Les objets (items) sont l'entité centrale de l'application. +Chaque objet appartient à une catégorie et est situé à un emplacement. +""" + +from datetime import date, datetime +from decimal import Decimal +from typing import TYPE_CHECKING + +from sqlalchemy import Date, DateTime, Enum, ForeignKey, Integer, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.category import Category + from app.models.document import Document + from app.models.location import Location + +import enum + + +class ItemStatus(str, enum.Enum): + """Statut d'un objet.""" + + IN_STOCK = "in_stock" # En stock (non utilisé) + IN_USE = "in_use" # En cours d'utilisation + BROKEN = "broken" # Cassé/HS + SOLD = "sold" # Vendu + LENT = "lent" # Prêté + + +class Item(Base): + """Objet de l'inventaire domestique. + + Attributes: + id: Identifiant unique auto-incrémenté + name: Nom de l'objet (ex: "Perceuse sans fil Bosch") + description: Description détaillée optionnelle + quantity: Quantité en stock (défaut: 1) + price: Prix d'achat optionnel + purchase_date: Date d'achat optionnelle + status: Statut de l'objet (in_stock/in_use/broken/sold/lent) + brand: Marque optionnelle (ex: "Bosch", "Samsung") + model: Modèle optionnel (ex: "PSR 18 LI-2") + serial_number: Numéro de série optionnel + notes: Notes libres + category_id: ID de la catégorie (FK) + location_id: ID de l'emplacement (FK) + created_at: Date/heure de création (auto) + updated_at: Date/heure de dernière modification (auto) + category: Relation vers la catégorie + location: Relation vers l'emplacement + documents: Relation vers les documents (photos, notices, factures) + """ + + __tablename__ = "items" + + # Colonnes principales + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(200), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + status: Mapped[ItemStatus] = mapped_column( + Enum(ItemStatus, native_enum=False, length=20), + nullable=False, + default=ItemStatus.IN_STOCK, + ) + + # Informations produit + brand: Mapped[str | None] = mapped_column(String(100), nullable=True) + model: Mapped[str | None] = mapped_column(String(100), nullable=True) + serial_number: Mapped[str | None] = mapped_column(String(100), nullable=True, unique=True) + url: Mapped[str | None] = mapped_column(String(500), nullable=True) # Lien vers page produit + + # Informations achat + price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True) + purchase_date: Mapped[date | None] = mapped_column(Date, nullable=True) + + # Notes + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Foreign Keys + category_id: Mapped[int] = mapped_column( + Integer, ForeignKey("categories.id", ondelete="RESTRICT"), nullable=False, index=True + ) + location_id: Mapped[int] = mapped_column( + Integer, ForeignKey("locations.id", ondelete="RESTRICT"), nullable=False, index=True + ) + + # Timestamps + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relations + category: Mapped["Category"] = relationship("Category", back_populates="items") + location: Mapped["Location"] = relationship("Location", back_populates="items") + documents: Mapped[list["Document"]] = relationship( + "Document", back_populates="item", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + """Représentation string de l'objet.""" + return f"" diff --git a/backend/app/models/location.py b/backend/app/models/location.py new file mode 100644 index 0000000..a028f8b --- /dev/null +++ b/backend/app/models/location.py @@ -0,0 +1,97 @@ +"""Modèle SQLAlchemy pour les emplacements de stockage. + +Les emplacements sont organisés de manière hiérarchique : +pièce → meuble → tiroir → boîte +Exemple : Garage → Étagère A → Tiroir 2 → Boîte visserie +""" + +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.item import Item + +import enum + + +class LocationType(str, enum.Enum): + """Type d'emplacement dans la hiérarchie.""" + + ROOM = "room" # Pièce (ex: Garage, Cuisine) + FURNITURE = "furniture" # Meuble (ex: Étagère, Armoire) + DRAWER = "drawer" # Tiroir + BOX = "box" # Boîte/Bac de rangement + + +class Location(Base): + """Emplacement de stockage hiérarchique. + + Attributes: + id: Identifiant unique auto-incrémenté + name: Nom de l'emplacement (ex: "Garage", "Étagère A") + type: Type d'emplacement (room/furniture/drawer/box) + parent_id: ID du parent (None si racine) + path: Chemin complet calculé (ex: "Garage > Étagère A > Tiroir 2") + description: Description optionnelle + created_at: Date/heure de création (auto) + updated_at: Date/heure de dernière modification (auto) + parent: Relation vers le parent + children: Relation vers les enfants + items: Relation vers les objets à cet emplacement + """ + + __tablename__ = "locations" + + # Colonnes + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + type: Mapped[LocationType] = mapped_column( + Enum(LocationType, native_enum=False, length=20), nullable=False + ) + parent_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("locations.id", ondelete="CASCADE"), nullable=True, index=True + ) + path: Mapped[str] = mapped_column(String(500), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(String(500), nullable=True) + + # Timestamps + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relations + parent: Mapped["Location | None"] = relationship( + "Location", remote_side=[id], back_populates="children" + ) + children: Mapped[list["Location"]] = relationship( + "Location", back_populates="parent", cascade="all, delete-orphan" + ) + items: Mapped[list["Item"]] = relationship( + "Item", back_populates="location", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + """Représentation string de l'emplacement.""" + return f"" + + def calculate_path(self) -> str: + """Calcule le chemin complet de l'emplacement. + + Returns: + Chemin complet (ex: "Garage > Étagère A > Tiroir 2") + """ + if self.parent is None: + return self.name + return f"{self.parent.calculate_path()} > {self.name}" diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..b46a475 --- /dev/null +++ b/backend/app/repositories/__init__.py @@ -0,0 +1,15 @@ +"""Package des repositories pour l'accès aux données.""" + +from app.repositories.base import BaseRepository +from app.repositories.category import CategoryRepository +from app.repositories.document import DocumentRepository +from app.repositories.item import ItemRepository +from app.repositories.location import LocationRepository + +__all__ = [ + "BaseRepository", + "CategoryRepository", + "LocationRepository", + "ItemRepository", + "DocumentRepository", +] diff --git a/backend/app/repositories/base.py b/backend/app/repositories/base.py new file mode 100644 index 0000000..ec13184 --- /dev/null +++ b/backend/app/repositories/base.py @@ -0,0 +1,154 @@ +"""Repository de base générique. + +Fournit les opérations CRUD de base pour tous les modèles. +""" + +from typing import Any, Generic, TypeVar + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import Base + +ModelType = TypeVar("ModelType", bound=Base) + + +class BaseRepository(Generic[ModelType]): + """Repository générique avec opérations CRUD de base. + + Attributes: + model: Classe du modèle SQLAlchemy + db: Session de base de données + """ + + def __init__(self, model: type[ModelType], db: AsyncSession) -> None: + """Initialise le repository. + + Args: + model: Classe du modèle SQLAlchemy + db: Session de base de données async + """ + self.model = model + self.db = db + + async def get(self, id: int) -> ModelType | None: + """Récupère un élément par son ID. + + Args: + id: Identifiant de l'élément + + Returns: + L'élément trouvé ou None + """ + result = await self.db.execute(select(self.model).where(self.model.id == id)) + return result.scalar_one_or_none() + + async def get_all( + self, skip: int = 0, limit: int = 100, **filters: Any + ) -> list[ModelType]: + """Récupère tous les éléments avec pagination et filtres optionnels. + + Args: + skip: Nombre d'éléments à sauter + limit: Nombre max d'éléments à retourner + **filters: Filtres additionnels (ex: status="active") + + Returns: + Liste des éléments + """ + query = select(self.model) + + # Appliquer les filtres + for field, value in filters.items(): + if value is not None and hasattr(self.model, field): + query = query.where(getattr(self.model, field) == value) + + query = query.offset(skip).limit(limit) + result = await self.db.execute(query) + return list(result.scalars().all()) + + async def count(self, **filters: Any) -> int: + """Compte le nombre d'éléments avec filtres optionnels. + + Args: + **filters: Filtres additionnels + + Returns: + Nombre total d'éléments + """ + query = select(func.count(self.model.id)) + + for field, value in filters.items(): + if value is not None and hasattr(self.model, field): + query = query.where(getattr(self.model, field) == value) + + result = await self.db.execute(query) + return result.scalar_one() + + async def create(self, **data: Any) -> ModelType: + """Crée un nouvel élément. + + Args: + **data: Données de l'élément + + Returns: + L'élément créé + """ + instance = self.model(**data) + self.db.add(instance) + await self.db.flush() + await self.db.refresh(instance) + return instance + + async def update(self, id: int, **data: Any) -> ModelType | None: + """Met à jour un élément existant. + + Args: + id: Identifiant de l'élément + **data: Données à mettre à jour (seules les valeurs non-None) + + Returns: + L'élément mis à jour ou None si non trouvé + """ + instance = await self.get(id) + if instance is None: + return None + + for field, value in data.items(): + if value is not None and hasattr(instance, field): + setattr(instance, field, value) + + await self.db.flush() + await self.db.refresh(instance) + return instance + + async def delete(self, id: int) -> bool: + """Supprime un élément. + + Args: + id: Identifiant de l'élément + + Returns: + True si supprimé, False si non trouvé + """ + instance = await self.get(id) + if instance is None: + return False + + await self.db.delete(instance) + await self.db.flush() + return True + + async def exists(self, id: int) -> bool: + """Vérifie si un élément existe. + + Args: + id: Identifiant de l'élément + + Returns: + True si existe, False sinon + """ + result = await self.db.execute( + select(func.count(self.model.id)).where(self.model.id == id) + ) + return result.scalar_one() > 0 diff --git a/backend/app/repositories/category.py b/backend/app/repositories/category.py new file mode 100644 index 0000000..cbf79ae --- /dev/null +++ b/backend/app/repositories/category.py @@ -0,0 +1,85 @@ +"""Repository pour les catégories.""" + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.category import Category +from app.repositories.base import BaseRepository + + +class CategoryRepository(BaseRepository[Category]): + """Repository pour les opérations sur les catégories.""" + + def __init__(self, db: AsyncSession) -> None: + """Initialise le repository.""" + super().__init__(Category, db) + + async def get_by_name(self, name: str) -> Category | None: + """Récupère une catégorie par son nom. + + Args: + name: Nom de la catégorie + + Returns: + La catégorie trouvée ou None + """ + result = await self.db.execute( + select(Category).where(Category.name == name) + ) + return result.scalar_one_or_none() + + async def get_with_item_count(self, id: int) -> tuple[Category, int] | None: + """Récupère une catégorie avec le nombre d'objets. + + Args: + id: ID de la catégorie + + Returns: + Tuple (catégorie, nombre d'objets) ou None + """ + result = await self.db.execute( + select(Category).options(selectinload(Category.items)).where(Category.id == id) + ) + category = result.scalar_one_or_none() + if category is None: + return None + return category, len(category.items) + + async def get_all_with_item_count( + self, skip: int = 0, limit: int = 100 + ) -> list[tuple[Category, int]]: + """Récupère toutes les catégories avec le nombre d'objets. + + Args: + skip: Offset + limit: Limite + + Returns: + Liste de tuples (catégorie, nombre d'objets) + """ + result = await self.db.execute( + select(Category) + .options(selectinload(Category.items)) + .offset(skip) + .limit(limit) + .order_by(Category.name) + ) + categories = result.scalars().all() + return [(cat, len(cat.items)) for cat in categories] + + async def name_exists(self, name: str, exclude_id: int | None = None) -> bool: + """Vérifie si un nom de catégorie existe déjà. + + Args: + name: Nom à vérifier + exclude_id: ID à exclure (pour les mises à jour) + + Returns: + True si le nom existe déjà + """ + query = select(func.count(Category.id)).where(Category.name == name) + if exclude_id is not None: + query = query.where(Category.id != exclude_id) + result = await self.db.execute(query) + return result.scalar_one() > 0 diff --git a/backend/app/repositories/document.py b/backend/app/repositories/document.py new file mode 100644 index 0000000..f418ee3 --- /dev/null +++ b/backend/app/repositories/document.py @@ -0,0 +1,113 @@ +"""Repository pour les documents attachés.""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.document import Document, DocumentType +from app.repositories.base import BaseRepository + + +class DocumentRepository(BaseRepository[Document]): + """Repository pour les opérations sur les documents.""" + + def __init__(self, db: AsyncSession) -> None: + """Initialise le repository.""" + super().__init__(Document, db) + + async def get_by_item(self, item_id: int) -> list[Document]: + """Récupère tous les documents d'un objet. + + Args: + item_id: ID de l'objet + + Returns: + Liste des documents + """ + result = await self.db.execute( + select(Document) + .where(Document.item_id == item_id) + .order_by(Document.type, Document.created_at) + ) + return list(result.scalars().all()) + + async def get_by_item_and_type( + self, item_id: int, type: DocumentType + ) -> list[Document]: + """Récupère les documents d'un objet par type. + + Args: + item_id: ID de l'objet + type: Type de document + + Returns: + Liste des documents + """ + result = await self.db.execute( + select(Document) + .where(Document.item_id == item_id, Document.type == type) + .order_by(Document.created_at) + ) + return list(result.scalars().all()) + + async def get_by_filename(self, filename: str) -> Document | None: + """Récupère un document par son nom de fichier. + + Args: + filename: Nom du fichier (UUID) + + Returns: + Le document trouvé ou None + """ + result = await self.db.execute( + select(Document).where(Document.filename == filename) + ) + return result.scalar_one_or_none() + + async def count_by_item(self, item_id: int) -> int: + """Compte le nombre de documents d'un objet. + + Args: + item_id: ID de l'objet + + Returns: + Nombre de documents + """ + from sqlalchemy import func + + result = await self.db.execute( + select(func.count(Document.id)).where(Document.item_id == item_id) + ) + return result.scalar_one() + + async def get_photos(self, item_id: int) -> list[Document]: + """Récupère les photos d'un objet. + + Args: + item_id: ID de l'objet + + Returns: + Liste des photos + """ + return await self.get_by_item_and_type(item_id, DocumentType.PHOTO) + + async def get_invoices(self, item_id: int) -> list[Document]: + """Récupère les factures d'un objet. + + Args: + item_id: ID de l'objet + + Returns: + Liste des factures + """ + return await self.get_by_item_and_type(item_id, DocumentType.INVOICE) + + async def get_manuals(self, item_id: int) -> list[Document]: + """Récupère les notices d'un objet. + + Args: + item_id: ID de l'objet + + Returns: + Liste des notices + """ + return await self.get_by_item_and_type(item_id, DocumentType.MANUAL) diff --git a/backend/app/repositories/item.py b/backend/app/repositories/item.py new file mode 100644 index 0000000..9affc3b --- /dev/null +++ b/backend/app/repositories/item.py @@ -0,0 +1,247 @@ +"""Repository pour les objets d'inventaire.""" + +from decimal import Decimal +from typing import Any + +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.item import Item, ItemStatus +from app.repositories.base import BaseRepository + + +class ItemRepository(BaseRepository[Item]): + """Repository pour les opérations sur les objets.""" + + def __init__(self, db: AsyncSession) -> None: + """Initialise le repository.""" + super().__init__(Item, db) + + async def get_with_relations(self, id: int) -> Item | None: + """Récupère un objet avec ses relations (catégorie, emplacement, documents). + + Args: + id: ID de l'objet + + Returns: + L'objet avec ses relations ou None + """ + result = await self.db.execute( + select(Item) + .options( + selectinload(Item.category), + selectinload(Item.location), + selectinload(Item.documents), + ) + .where(Item.id == id) + ) + return result.scalar_one_or_none() + + async def get_all_with_relations( + self, skip: int = 0, limit: int = 100 + ) -> list[Item]: + """Récupère tous les objets avec leurs relations. + + Args: + skip: Offset + limit: Limite + + Returns: + Liste des objets avec relations + """ + result = await self.db.execute( + select(Item) + .options( + selectinload(Item.category), + selectinload(Item.location), + ) + .offset(skip) + .limit(limit) + .order_by(Item.name) + ) + return list(result.scalars().all()) + + async def search( + self, + query: str, + category_id: int | None = None, + location_id: int | None = None, + status: ItemStatus | None = None, + min_price: Decimal | None = None, + max_price: Decimal | None = None, + skip: int = 0, + limit: int = 100, + ) -> list[Item]: + """Recherche des objets avec filtres. + + Args: + query: Texte de recherche (nom, description, marque, modèle) + category_id: Filtre par catégorie + location_id: Filtre par emplacement + status: Filtre par statut + min_price: Prix minimum + max_price: Prix maximum + skip: Offset + limit: Limite + + Returns: + Liste des objets correspondants + """ + stmt = select(Item).options( + selectinload(Item.category), + selectinload(Item.location), + ) + + # Recherche textuelle + if query: + search_term = f"%{query}%" + stmt = stmt.where( + or_( + Item.name.ilike(search_term), + Item.description.ilike(search_term), + Item.brand.ilike(search_term), + Item.model.ilike(search_term), + Item.notes.ilike(search_term), + ) + ) + + # Filtres + if category_id is not None: + stmt = stmt.where(Item.category_id == category_id) + if location_id is not None: + stmt = stmt.where(Item.location_id == location_id) + if status is not None: + stmt = stmt.where(Item.status == status) + if min_price is not None: + stmt = stmt.where(Item.price >= min_price) + if max_price is not None: + stmt = stmt.where(Item.price <= max_price) + + stmt = stmt.offset(skip).limit(limit).order_by(Item.name) + result = await self.db.execute(stmt) + return list(result.scalars().all()) + + async def count_filtered( + self, + query: str | None = None, + category_id: int | None = None, + location_id: int | None = None, + status: ItemStatus | None = None, + min_price: Decimal | None = None, + max_price: Decimal | None = None, + ) -> int: + """Compte les objets avec filtres. + + Returns: + Nombre d'objets correspondants + """ + from sqlalchemy import func + + stmt = select(func.count(Item.id)) + + if query: + search_term = f"%{query}%" + stmt = stmt.where( + or_( + Item.name.ilike(search_term), + Item.description.ilike(search_term), + Item.brand.ilike(search_term), + Item.model.ilike(search_term), + Item.notes.ilike(search_term), + ) + ) + + if category_id is not None: + stmt = stmt.where(Item.category_id == category_id) + if location_id is not None: + stmt = stmt.where(Item.location_id == location_id) + if status is not None: + stmt = stmt.where(Item.status == status) + if min_price is not None: + stmt = stmt.where(Item.price >= min_price) + if max_price is not None: + stmt = stmt.where(Item.price <= max_price) + + result = await self.db.execute(stmt) + return result.scalar_one() + + async def get_by_category( + self, category_id: int, skip: int = 0, limit: int = 100 + ) -> list[Item]: + """Récupère les objets d'une catégorie. + + Args: + category_id: ID de la catégorie + skip: Offset + limit: Limite + + Returns: + Liste des objets + """ + result = await self.db.execute( + select(Item) + .where(Item.category_id == category_id) + .offset(skip) + .limit(limit) + .order_by(Item.name) + ) + return list(result.scalars().all()) + + async def get_by_location( + self, location_id: int, skip: int = 0, limit: int = 100 + ) -> list[Item]: + """Récupère les objets d'un emplacement. + + Args: + location_id: ID de l'emplacement + skip: Offset + limit: Limite + + Returns: + Liste des objets + """ + result = await self.db.execute( + select(Item) + .where(Item.location_id == location_id) + .offset(skip) + .limit(limit) + .order_by(Item.name) + ) + return list(result.scalars().all()) + + async def get_by_status( + self, status: ItemStatus, skip: int = 0, limit: int = 100 + ) -> list[Item]: + """Récupère les objets par statut. + + Args: + status: Statut recherché + skip: Offset + limit: Limite + + Returns: + Liste des objets + """ + result = await self.db.execute( + select(Item) + .where(Item.status == status) + .offset(skip) + .limit(limit) + .order_by(Item.name) + ) + return list(result.scalars().all()) + + async def get_by_serial_number(self, serial_number: str) -> Item | None: + """Récupère un objet par son numéro de série. + + Args: + serial_number: Numéro de série + + Returns: + L'objet trouvé ou None + """ + result = await self.db.execute( + select(Item).where(Item.serial_number == serial_number) + ) + return result.scalar_one_or_none() diff --git a/backend/app/repositories/location.py b/backend/app/repositories/location.py new file mode 100644 index 0000000..a906c2a --- /dev/null +++ b/backend/app/repositories/location.py @@ -0,0 +1,171 @@ +"""Repository pour les emplacements.""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.location import Location, LocationType +from app.repositories.base import BaseRepository + + +class LocationRepository(BaseRepository[Location]): + """Repository pour les opérations sur les emplacements.""" + + def __init__(self, db: AsyncSession) -> None: + """Initialise le repository.""" + super().__init__(Location, db) + + async def get_with_children(self, id: int) -> Location | None: + """Récupère un emplacement avec ses enfants. + + Args: + id: ID de l'emplacement + + Returns: + L'emplacement avec ses enfants ou None + """ + result = await self.db.execute( + select(Location) + .options(selectinload(Location.children)) + .where(Location.id == id) + ) + return result.scalar_one_or_none() + + async def get_root_locations(self) -> list[Location]: + """Récupère tous les emplacements racine (sans parent). + + Returns: + Liste des emplacements racine + """ + result = await self.db.execute( + select(Location) + .where(Location.parent_id.is_(None)) + .order_by(Location.name) + ) + return list(result.scalars().all()) + + async def get_children(self, parent_id: int) -> list[Location]: + """Récupère les enfants directs d'un emplacement. + + Args: + parent_id: ID du parent + + Returns: + Liste des enfants + """ + result = await self.db.execute( + select(Location) + .where(Location.parent_id == parent_id) + .order_by(Location.name) + ) + return list(result.scalars().all()) + + async def get_by_type(self, type: LocationType) -> list[Location]: + """Récupère tous les emplacements d'un type donné. + + Args: + type: Type d'emplacement + + Returns: + Liste des emplacements + """ + result = await self.db.execute( + select(Location) + .where(Location.type == type) + .order_by(Location.path) + ) + return list(result.scalars().all()) + + async def get_full_tree(self) -> list[Location]: + """Récupère l'arborescence complète des emplacements. + + Returns: + Liste des emplacements racine avec enfants chargés récursivement + """ + # Charger tous les emplacements avec leurs enfants + result = await self.db.execute( + select(Location) + .options(selectinload(Location.children)) + .order_by(Location.path) + ) + all_locations = list(result.scalars().all()) + + # Retourner seulement les racines (les enfants sont déjà chargés) + return [loc for loc in all_locations if loc.parent_id is None] + + async def get_with_item_count(self, id: int) -> tuple[Location, int] | None: + """Récupère un emplacement avec le nombre d'objets. + + Args: + id: ID de l'emplacement + + Returns: + Tuple (emplacement, nombre d'objets) ou None + """ + result = await self.db.execute( + select(Location) + .options(selectinload(Location.items)) + .where(Location.id == id) + ) + location = result.scalar_one_or_none() + if location is None: + return None + return location, len(location.items) + + async def create_with_path( + self, + name: str, + type: LocationType, + parent_id: int | None = None, + description: str | None = None, + ) -> Location: + """Crée un emplacement avec calcul automatique du chemin. + + Args: + name: Nom de l'emplacement + type: Type d'emplacement + parent_id: ID du parent (None si racine) + description: Description optionnelle + + Returns: + L'emplacement créé + """ + # Calculer le chemin + if parent_id is None: + path = name + else: + parent = await self.get(parent_id) + if parent is None: + path = name + else: + path = f"{parent.path} > {name}" + + return await self.create( + name=name, + type=type, + parent_id=parent_id, + path=path, + description=description, + ) + + async def update_paths_recursive(self, location: Location) -> None: + """Met à jour récursivement les chemins après modification. + + Args: + location: Emplacement modifié + """ + # Mettre à jour le chemin de cet emplacement + if location.parent_id is None: + location.path = location.name + else: + parent = await self.get(location.parent_id) + if parent: + location.path = f"{parent.path} > {location.name}" + else: + location.path = location.name + + # Mettre à jour les enfants + children = await self.get_children(location.id) + for child in children: + child.path = f"{location.path} > {child.name}" + await self.update_paths_recursive(child) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..9e1544b --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1,11 @@ +"""Package des routers API.""" + +from app.routers.categories import router as categories_router +from app.routers.items import router as items_router +from app.routers.locations import router as locations_router + +__all__ = [ + "categories_router", + "locations_router", + "items_router", +] diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py new file mode 100644 index 0000000..9a5effb --- /dev/null +++ b/backend/app/routers/categories.py @@ -0,0 +1,168 @@ +"""Router API pour les catégories.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.repositories.category import CategoryRepository +from app.schemas.category import ( + CategoryCreate, + CategoryResponse, + CategoryUpdate, + CategoryWithItemCount, +) +from app.schemas.common import PaginatedResponse, SuccessResponse + +router = APIRouter(prefix="/categories", tags=["Categories"]) + + +@router.get("", response_model=PaginatedResponse[CategoryWithItemCount]) +async def list_categories( + page: int = 1, + page_size: int = 20, + db: AsyncSession = Depends(get_db), +) -> PaginatedResponse[CategoryWithItemCount]: + """Liste toutes les catégories avec le nombre d'objets.""" + repo = CategoryRepository(db) + skip = (page - 1) * page_size + + categories_with_count = await repo.get_all_with_item_count(skip=skip, limit=page_size) + total = await repo.count() + + items = [ + CategoryWithItemCount( + id=cat.id, + name=cat.name, + description=cat.description, + color=cat.color, + icon=cat.icon, + created_at=cat.created_at, + updated_at=cat.updated_at, + item_count=count, + ) + for cat, count in categories_with_count + ] + + return PaginatedResponse.create(items=items, total=total, page=page, page_size=page_size) + + +@router.get("/{category_id}", response_model=CategoryWithItemCount) +async def get_category( + category_id: int, + db: AsyncSession = Depends(get_db), +) -> CategoryWithItemCount: + """Récupère une catégorie par son ID.""" + repo = CategoryRepository(db) + result = await repo.get_with_item_count(category_id) + + if result is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Catégorie {category_id} non trouvée", + ) + + category, item_count = result + return CategoryWithItemCount( + id=category.id, + name=category.name, + description=category.description, + color=category.color, + icon=category.icon, + created_at=category.created_at, + updated_at=category.updated_at, + item_count=item_count, + ) + + +@router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED) +async def create_category( + data: CategoryCreate, + db: AsyncSession = Depends(get_db), +) -> CategoryResponse: + """Crée une nouvelle catégorie.""" + repo = CategoryRepository(db) + + # Vérifier si le nom existe déjà + if await repo.name_exists(data.name): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Une catégorie avec le nom '{data.name}' existe déjà", + ) + + category = await repo.create( + name=data.name, + description=data.description, + color=data.color, + icon=data.icon, + ) + await db.commit() + + return CategoryResponse.model_validate(category) + + +@router.put("/{category_id}", response_model=CategoryResponse) +async def update_category( + category_id: int, + data: CategoryUpdate, + db: AsyncSession = Depends(get_db), +) -> CategoryResponse: + """Met à jour une catégorie.""" + repo = CategoryRepository(db) + + # Vérifier si la catégorie existe + existing = await repo.get(category_id) + if existing is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Catégorie {category_id} non trouvée", + ) + + # Vérifier si le nouveau nom existe déjà (si changement de nom) + if data.name and data.name != existing.name: + if await repo.name_exists(data.name, exclude_id=category_id): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Une catégorie avec le nom '{data.name}' existe déjà", + ) + + category = await repo.update( + category_id, + name=data.name, + description=data.description, + color=data.color, + icon=data.icon, + ) + await db.commit() + + return CategoryResponse.model_validate(category) + + +@router.delete("/{category_id}", response_model=SuccessResponse) +async def delete_category( + category_id: int, + db: AsyncSession = Depends(get_db), +) -> SuccessResponse: + """Supprime une catégorie.""" + repo = CategoryRepository(db) + + # Vérifier si la catégorie existe + result = await repo.get_with_item_count(category_id) + if result is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Catégorie {category_id} non trouvée", + ) + + category, item_count = result + + # Empêcher la suppression si des objets utilisent cette catégorie + if item_count > 0: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Impossible de supprimer : {item_count} objet(s) utilisent cette catégorie", + ) + + await repo.delete(category_id) + await db.commit() + + return SuccessResponse(message="Catégorie supprimée avec succès", id=category_id) diff --git a/backend/app/routers/items.py b/backend/app/routers/items.py new file mode 100644 index 0000000..d011a11 --- /dev/null +++ b/backend/app/routers/items.py @@ -0,0 +1,264 @@ +"""Router API pour les objets d'inventaire.""" + +from decimal import Decimal + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.models.item import ItemStatus +from app.repositories.category import CategoryRepository +from app.repositories.item import ItemRepository +from app.repositories.location import LocationRepository +from app.schemas.common import PaginatedResponse, SuccessResponse +from app.schemas.item import ( + ItemCreate, + ItemResponse, + ItemUpdate, + ItemWithRelations, +) + +router = APIRouter(prefix="/items", tags=["Items"]) + + +@router.get("", response_model=PaginatedResponse[ItemWithRelations]) +async def list_items( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + search: str | None = Query(default=None, min_length=2), + category_id: int | None = None, + location_id: int | None = None, + status: ItemStatus | None = None, + min_price: Decimal | None = None, + max_price: Decimal | None = None, + db: AsyncSession = Depends(get_db), +) -> PaginatedResponse[ItemWithRelations]: + """Liste les objets avec filtres et pagination.""" + repo = ItemRepository(db) + skip = (page - 1) * page_size + + items = await repo.search( + query=search or "", + category_id=category_id, + location_id=location_id, + status=status, + min_price=min_price, + max_price=max_price, + skip=skip, + limit=page_size, + ) + + total = await repo.count_filtered( + query=search, + category_id=category_id, + location_id=location_id, + status=status, + min_price=min_price, + max_price=max_price, + ) + + result_items = [ItemWithRelations.model_validate(item) for item in items] + return PaginatedResponse.create(items=result_items, total=total, page=page, page_size=page_size) + + +@router.get("/{item_id}", response_model=ItemWithRelations) +async def get_item( + item_id: int, + db: AsyncSession = Depends(get_db), +) -> ItemWithRelations: + """Récupère un objet par son ID avec ses relations.""" + repo = ItemRepository(db) + item = await repo.get_with_relations(item_id) + + if item is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Objet {item_id} non trouvé", + ) + + return ItemWithRelations.model_validate(item) + + +@router.post("", response_model=ItemResponse, status_code=status.HTTP_201_CREATED) +async def create_item( + data: ItemCreate, + db: AsyncSession = Depends(get_db), +) -> ItemResponse: + """Crée un nouvel objet.""" + item_repo = ItemRepository(db) + category_repo = CategoryRepository(db) + location_repo = LocationRepository(db) + + # Vérifier que la catégorie existe + if not await category_repo.exists(data.category_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Catégorie {data.category_id} non trouvée", + ) + + # Vérifier que l'emplacement existe + if not await location_repo.exists(data.location_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Emplacement {data.location_id} non trouvé", + ) + + # Vérifier l'unicité du numéro de série si fourni + if data.serial_number: + existing = await item_repo.get_by_serial_number(data.serial_number) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Un objet avec le numéro de série '{data.serial_number}' existe déjà", + ) + + item = await item_repo.create( + name=data.name, + description=data.description, + quantity=data.quantity, + status=data.status, + brand=data.brand, + model=data.model, + serial_number=data.serial_number, + price=data.price, + purchase_date=data.purchase_date, + notes=data.notes, + category_id=data.category_id, + location_id=data.location_id, + ) + await db.commit() + + return ItemResponse.model_validate(item) + + +@router.put("/{item_id}", response_model=ItemResponse) +async def update_item( + item_id: int, + data: ItemUpdate, + db: AsyncSession = Depends(get_db), +) -> ItemResponse: + """Met à jour un objet.""" + item_repo = ItemRepository(db) + category_repo = CategoryRepository(db) + location_repo = LocationRepository(db) + + # Vérifier que l'objet existe + existing = await item_repo.get(item_id) + if existing is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Objet {item_id} non trouvé", + ) + + # Vérifier la catégorie si changée + if data.category_id is not None and data.category_id != existing.category_id: + if not await category_repo.exists(data.category_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Catégorie {data.category_id} non trouvée", + ) + + # Vérifier l'emplacement si changé + if data.location_id is not None and data.location_id != existing.location_id: + if not await location_repo.exists(data.location_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Emplacement {data.location_id} non trouvé", + ) + + # Vérifier l'unicité du numéro de série si changé + if data.serial_number and data.serial_number != existing.serial_number: + existing_with_serial = await item_repo.get_by_serial_number(data.serial_number) + if existing_with_serial and existing_with_serial.id != item_id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Un objet avec le numéro de série '{data.serial_number}' existe déjà", + ) + + item = await item_repo.update( + item_id, + name=data.name, + description=data.description, + quantity=data.quantity, + status=data.status, + brand=data.brand, + model=data.model, + serial_number=data.serial_number, + price=data.price, + purchase_date=data.purchase_date, + notes=data.notes, + category_id=data.category_id, + location_id=data.location_id, + ) + await db.commit() + + return ItemResponse.model_validate(item) + + +@router.delete("/{item_id}", response_model=SuccessResponse) +async def delete_item( + item_id: int, + db: AsyncSession = Depends(get_db), +) -> SuccessResponse: + """Supprime un objet et ses documents associés.""" + repo = ItemRepository(db) + + # Vérifier que l'objet existe + if not await repo.exists(item_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Objet {item_id} non trouvé", + ) + + await repo.delete(item_id) + await db.commit() + + return SuccessResponse(message="Objet supprimé avec succès", id=item_id) + + +@router.patch("/{item_id}/status", response_model=ItemResponse) +async def update_item_status( + item_id: int, + new_status: ItemStatus, + db: AsyncSession = Depends(get_db), +) -> ItemResponse: + """Met à jour le statut d'un objet.""" + repo = ItemRepository(db) + + item = await repo.update(item_id, status=new_status) + if item is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Objet {item_id} non trouvé", + ) + + await db.commit() + return ItemResponse.model_validate(item) + + +@router.patch("/{item_id}/location", response_model=ItemResponse) +async def move_item( + item_id: int, + new_location_id: int, + db: AsyncSession = Depends(get_db), +) -> ItemResponse: + """Déplace un objet vers un nouvel emplacement.""" + item_repo = ItemRepository(db) + location_repo = LocationRepository(db) + + # Vérifier que le nouvel emplacement existe + if not await location_repo.exists(new_location_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Emplacement {new_location_id} non trouvé", + ) + + item = await item_repo.update(item_id, location_id=new_location_id) + if item is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Objet {item_id} non trouvé", + ) + + await db.commit() + return ItemResponse.model_validate(item) diff --git a/backend/app/routers/locations.py b/backend/app/routers/locations.py new file mode 100644 index 0000000..1b95f5b --- /dev/null +++ b/backend/app/routers/locations.py @@ -0,0 +1,249 @@ +"""Router API pour les emplacements.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.models.location import LocationType +from app.repositories.location import LocationRepository +from app.schemas.common import PaginatedResponse, SuccessResponse +from app.schemas.location import ( + LocationCreate, + LocationResponse, + LocationTree, + LocationUpdate, + LocationWithItemCount, +) + +router = APIRouter(prefix="/locations", tags=["Locations"]) + + +@router.get("", response_model=PaginatedResponse[LocationResponse]) +async def list_locations( + page: int = 1, + page_size: int = 50, + parent_id: int | None = None, + type: LocationType | None = None, + db: AsyncSession = Depends(get_db), +) -> PaginatedResponse[LocationResponse]: + """Liste les emplacements avec filtres optionnels.""" + repo = LocationRepository(db) + skip = (page - 1) * page_size + + filters = {} + if parent_id is not None: + filters["parent_id"] = parent_id + if type is not None: + filters["type"] = type + + locations = await repo.get_all(skip=skip, limit=page_size, **filters) + total = await repo.count(**filters) + + items = [LocationResponse.model_validate(loc) for loc in locations] + return PaginatedResponse.create(items=items, total=total, page=page, page_size=page_size) + + +@router.get("/tree", response_model=list[LocationTree]) +async def get_location_tree( + db: AsyncSession = Depends(get_db), +) -> list[LocationTree]: + """Récupère l'arborescence complète des emplacements.""" + repo = LocationRepository(db) + + # Récupérer tous les emplacements + all_locations = await repo.get_all(skip=0, limit=1000) + + # Construire un dictionnaire pour un accès rapide + loc_dict: dict[int, LocationTree] = {} + for loc in all_locations: + loc_dict[loc.id] = LocationTree( + id=loc.id, + name=loc.name, + type=loc.type, + path=loc.path, + children=[], + item_count=0, + ) + + # Construire l'arborescence + roots: list[LocationTree] = [] + for loc in all_locations: + tree_node = loc_dict[loc.id] + if loc.parent_id is None: + roots.append(tree_node) + elif loc.parent_id in loc_dict: + loc_dict[loc.parent_id].children.append(tree_node) + + return roots + + +@router.get("/roots", response_model=list[LocationResponse]) +async def get_root_locations( + db: AsyncSession = Depends(get_db), +) -> list[LocationResponse]: + """Récupère les emplacements racine (pièces).""" + repo = LocationRepository(db) + locations = await repo.get_root_locations() + return [LocationResponse.model_validate(loc) for loc in locations] + + +@router.get("/{location_id}", response_model=LocationWithItemCount) +async def get_location( + location_id: int, + db: AsyncSession = Depends(get_db), +) -> LocationWithItemCount: + """Récupère un emplacement par son ID.""" + repo = LocationRepository(db) + result = await repo.get_with_item_count(location_id) + + if result is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Emplacement {location_id} non trouvé", + ) + + location, item_count = result + return LocationWithItemCount( + id=location.id, + name=location.name, + type=location.type, + parent_id=location.parent_id, + path=location.path, + description=location.description, + created_at=location.created_at, + updated_at=location.updated_at, + item_count=item_count, + ) + + +@router.get("/{location_id}/children", response_model=list[LocationResponse]) +async def get_location_children( + location_id: int, + db: AsyncSession = Depends(get_db), +) -> list[LocationResponse]: + """Récupère les enfants directs d'un emplacement.""" + repo = LocationRepository(db) + + # Vérifier que le parent existe + if not await repo.exists(location_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Emplacement {location_id} non trouvé", + ) + + children = await repo.get_children(location_id) + return [LocationResponse.model_validate(child) for child in children] + + +@router.post("", response_model=LocationResponse, status_code=status.HTTP_201_CREATED) +async def create_location( + data: LocationCreate, + db: AsyncSession = Depends(get_db), +) -> LocationResponse: + """Crée un nouvel emplacement.""" + repo = LocationRepository(db) + + # Vérifier que le parent existe si spécifié + if data.parent_id is not None: + if not await repo.exists(data.parent_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Emplacement parent {data.parent_id} non trouvé", + ) + + location = await repo.create_with_path( + name=data.name, + type=data.type, + parent_id=data.parent_id, + description=data.description, + ) + await db.commit() + + return LocationResponse.model_validate(location) + + +@router.put("/{location_id}", response_model=LocationResponse) +async def update_location( + location_id: int, + data: LocationUpdate, + db: AsyncSession = Depends(get_db), +) -> LocationResponse: + """Met à jour un emplacement.""" + repo = LocationRepository(db) + + # Vérifier que l'emplacement existe + existing = await repo.get(location_id) + if existing is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Emplacement {location_id} non trouvé", + ) + + # Vérifier que le nouveau parent existe si spécifié + if data.parent_id is not None and data.parent_id != existing.parent_id: + if data.parent_id == location_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Un emplacement ne peut pas être son propre parent", + ) + if not await repo.exists(data.parent_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Emplacement parent {data.parent_id} non trouvé", + ) + + # Mettre à jour + location = await repo.update( + location_id, + name=data.name, + type=data.type, + parent_id=data.parent_id, + description=data.description, + ) + + # Recalculer les chemins si le nom ou le parent a changé + if data.name or data.parent_id is not None: + await repo.update_paths_recursive(location) + + await db.commit() + + return LocationResponse.model_validate(location) + + +@router.delete("/{location_id}", response_model=SuccessResponse) +async def delete_location( + location_id: int, + db: AsyncSession = Depends(get_db), +) -> SuccessResponse: + """Supprime un emplacement.""" + repo = LocationRepository(db) + + # Vérifier que l'emplacement existe + result = await repo.get_with_item_count(location_id) + if result is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Emplacement {location_id} non trouvé", + ) + + location, item_count = result + + # Empêcher la suppression si des objets utilisent cet emplacement + if item_count > 0: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Impossible de supprimer : {item_count} objet(s) utilisent cet emplacement", + ) + + # Vérifier s'il y a des enfants + children = await repo.get_children(location_id) + if children: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Impossible de supprimer : cet emplacement a {len(children)} sous-emplacement(s)", + ) + + await repo.delete(location_id) + await db.commit() + + return SuccessResponse(message="Emplacement supprimé avec succès", id=location_id) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..2ac2d14 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,68 @@ +"""Package des schémas Pydantic.""" + +from app.schemas.category import ( + CategoryCreate, + CategoryResponse, + CategoryUpdate, + CategoryWithItemCount, +) +from app.schemas.common import ( + ErrorResponse, + PaginatedResponse, + PaginationParams, + SuccessResponse, +) +from app.schemas.document import ( + DocumentCreate, + DocumentResponse, + DocumentUpdate, + DocumentUploadResponse, +) +from app.schemas.item import ( + ItemCreate, + ItemFilter, + ItemResponse, + ItemSummary, + ItemUpdate, + ItemWithRelations, +) +from app.schemas.location import ( + LocationCreate, + LocationResponse, + LocationTree, + LocationUpdate, + LocationWithChildren, + LocationWithItemCount, +) + +__all__ = [ + # Category + "CategoryCreate", + "CategoryUpdate", + "CategoryResponse", + "CategoryWithItemCount", + # Location + "LocationCreate", + "LocationUpdate", + "LocationResponse", + "LocationWithChildren", + "LocationWithItemCount", + "LocationTree", + # Item + "ItemCreate", + "ItemUpdate", + "ItemResponse", + "ItemWithRelations", + "ItemSummary", + "ItemFilter", + # Document + "DocumentCreate", + "DocumentUpdate", + "DocumentResponse", + "DocumentUploadResponse", + # Common + "PaginationParams", + "PaginatedResponse", + "ErrorResponse", + "SuccessResponse", +] diff --git a/backend/app/schemas/category.py b/backend/app/schemas/category.py new file mode 100644 index 0000000..32d49c5 --- /dev/null +++ b/backend/app/schemas/category.py @@ -0,0 +1,48 @@ +"""Schémas Pydantic pour les catégories. + +Définit les schémas de validation pour les requêtes et réponses API. +""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class CategoryBase(BaseModel): + """Schéma de base pour les catégories.""" + + name: str = Field(..., min_length=1, max_length=100, description="Nom de la catégorie") + description: str | None = Field(None, max_length=1000, description="Description optionnelle") + color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$", description="Couleur hex (#RRGGBB)") + icon: str | None = Field(None, max_length=50, description="Nom de l'icône") + + +class CategoryCreate(CategoryBase): + """Schéma pour la création d'une catégorie.""" + + pass + + +class CategoryUpdate(BaseModel): + """Schéma pour la mise à jour d'une catégorie (tous les champs optionnels).""" + + name: str | None = Field(None, min_length=1, max_length=100) + description: str | None = Field(None, max_length=1000) + color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$") + icon: str | None = Field(None, max_length=50) + + +class CategoryResponse(CategoryBase): + """Schéma de réponse pour une catégorie.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + created_at: datetime + updated_at: datetime + + +class CategoryWithItemCount(CategoryResponse): + """Schéma de réponse avec le nombre d'objets.""" + + item_count: int = Field(default=0, description="Nombre d'objets dans cette catégorie") diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py new file mode 100644 index 0000000..2455134 --- /dev/null +++ b/backend/app/schemas/common.py @@ -0,0 +1,60 @@ +"""Schémas Pydantic communs. + +Définit les schémas réutilisables (pagination, erreurs, etc.). +""" + +from typing import Generic, TypeVar + +from pydantic import BaseModel, Field + +T = TypeVar("T") + + +class PaginationParams(BaseModel): + """Paramètres de pagination.""" + + page: int = Field(default=1, ge=1, description="Numéro de page (commence à 1)") + page_size: int = Field(default=20, ge=1, le=100, description="Nombre d'éléments par page") + + @property + def offset(self) -> int: + """Calcule l'offset pour la requête SQL.""" + return (self.page - 1) * self.page_size + + +class PaginatedResponse(BaseModel, Generic[T]): + """Réponse paginée générique.""" + + items: list[T] + total: int = Field(..., description="Nombre total d'éléments") + page: int = Field(..., description="Page actuelle") + page_size: int = Field(..., description="Taille de la page") + pages: int = Field(..., description="Nombre total de pages") + + @classmethod + def create( + cls, items: list[T], total: int, page: int, page_size: int + ) -> "PaginatedResponse[T]": + """Crée une réponse paginée.""" + pages = (total + page_size - 1) // page_size if page_size > 0 else 0 + return cls( + items=items, + total=total, + page=page, + page_size=page_size, + pages=pages, + ) + + +class ErrorResponse(BaseModel): + """Schéma de réponse d'erreur.""" + + detail: str = Field(..., description="Message d'erreur") + type: str = Field(..., description="Type d'erreur") + + +class SuccessResponse(BaseModel): + """Schéma de réponse de succès.""" + + message: str = Field(..., description="Message de succès") + id: int | None = Field(None, description="ID de l'élément concerné") diff --git a/backend/app/schemas/document.py b/backend/app/schemas/document.py new file mode 100644 index 0000000..5997f88 --- /dev/null +++ b/backend/app/schemas/document.py @@ -0,0 +1,63 @@ +"""Schémas Pydantic pour les documents attachés. + +Définit les schémas de validation pour les requêtes et réponses API. +""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.document import DocumentType + + +class DocumentBase(BaseModel): + """Schéma de base pour les documents.""" + + type: DocumentType = Field(..., description="Type de document") + description: str | None = Field(None, max_length=500, description="Description optionnelle") + + +class DocumentCreate(DocumentBase): + """Schéma pour la création d'un document (métadonnées seulement). + + Le fichier est uploadé séparément via multipart/form-data. + """ + + item_id: int = Field(..., description="ID de l'objet associé") + + +class DocumentUpdate(BaseModel): + """Schéma pour la mise à jour d'un document.""" + + type: DocumentType | None = None + description: str | None = Field(None, max_length=500) + + +class DocumentResponse(BaseModel): + """Schéma de réponse pour un document.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + filename: str + original_name: str + type: DocumentType + mime_type: str + size_bytes: int + file_path: str + description: str | None + item_id: int + created_at: datetime + updated_at: datetime + + +class DocumentUploadResponse(BaseModel): + """Schéma de réponse après upload d'un document.""" + + id: int + filename: str + original_name: str + type: DocumentType + mime_type: str + size_bytes: int + message: str = "Document uploadé avec succès" diff --git a/backend/app/schemas/item.py b/backend/app/schemas/item.py new file mode 100644 index 0000000..27e7f91 --- /dev/null +++ b/backend/app/schemas/item.py @@ -0,0 +1,98 @@ +"""Schémas Pydantic pour les objets d'inventaire. + +Définit les schémas de validation pour les requêtes et réponses API. +""" + +from datetime import date, datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.item import ItemStatus +from app.schemas.category import CategoryResponse +from app.schemas.location import LocationResponse + + +class ItemBase(BaseModel): + """Schéma de base pour les objets.""" + + name: str = Field(..., min_length=1, max_length=200, description="Nom de l'objet") + description: str | None = Field(None, description="Description détaillée") + quantity: int = Field(default=1, ge=0, description="Quantité en stock") + status: ItemStatus = Field(default=ItemStatus.IN_STOCK, description="Statut de l'objet") + brand: str | None = Field(None, max_length=100, description="Marque") + model: str | None = Field(None, max_length=100, description="Modèle") + serial_number: str | None = Field(None, max_length=100, description="Numéro de série") + url: str | None = Field(None, max_length=500, description="Lien vers page produit") + price: Decimal | None = Field(None, ge=0, decimal_places=2, description="Prix d'achat") + purchase_date: date | None = Field(None, description="Date d'achat") + notes: str | None = Field(None, description="Notes libres") + + +class ItemCreate(ItemBase): + """Schéma pour la création d'un objet.""" + + category_id: int = Field(..., description="ID de la catégorie") + location_id: int = Field(..., description="ID de l'emplacement") + + +class ItemUpdate(BaseModel): + """Schéma pour la mise à jour d'un objet (tous les champs optionnels).""" + + name: str | None = Field(None, min_length=1, max_length=200) + description: str | None = None + quantity: int | None = Field(None, ge=0) + status: ItemStatus | None = None + brand: str | None = Field(None, max_length=100) + model: str | None = Field(None, max_length=100) + serial_number: str | None = Field(None, max_length=100) + url: str | None = Field(None, max_length=500) + price: Decimal | None = Field(None, ge=0) + purchase_date: date | None = None + notes: str | None = None + category_id: int | None = None + location_id: int | None = None + + +class ItemResponse(ItemBase): + """Schéma de réponse pour un objet.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + category_id: int + location_id: int + created_at: datetime + updated_at: datetime + + +class ItemWithRelations(ItemResponse): + """Schéma de réponse avec les relations (catégorie et emplacement).""" + + category: CategoryResponse + location: LocationResponse + + +class ItemSummary(BaseModel): + """Schéma résumé pour les listes d'objets.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + quantity: int + status: ItemStatus + brand: str | None + category_id: int + location_id: int + + +class ItemFilter(BaseModel): + """Schéma pour filtrer les objets.""" + + category_id: int | None = None + location_id: int | None = None + status: ItemStatus | None = None + search: str | None = Field(None, min_length=2, description="Recherche textuelle") + min_price: Decimal | None = None + max_price: Decimal | None = None diff --git a/backend/app/schemas/location.py b/backend/app/schemas/location.py new file mode 100644 index 0000000..58b1c91 --- /dev/null +++ b/backend/app/schemas/location.py @@ -0,0 +1,70 @@ +"""Schémas Pydantic pour les emplacements. + +Définit les schémas de validation pour les requêtes et réponses API. +""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.location import LocationType + + +class LocationBase(BaseModel): + """Schéma de base pour les emplacements.""" + + name: str = Field(..., min_length=1, max_length=100, description="Nom de l'emplacement") + type: LocationType = Field(..., description="Type d'emplacement") + description: str | None = Field(None, max_length=500, description="Description optionnelle") + + +class LocationCreate(LocationBase): + """Schéma pour la création d'un emplacement.""" + + parent_id: int | None = Field(None, description="ID du parent (None si racine)") + + +class LocationUpdate(BaseModel): + """Schéma pour la mise à jour d'un emplacement (tous les champs optionnels).""" + + name: str | None = Field(None, min_length=1, max_length=100) + type: LocationType | None = None + description: str | None = Field(None, max_length=500) + parent_id: int | None = None + + +class LocationResponse(LocationBase): + """Schéma de réponse pour un emplacement.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + parent_id: int | None + path: str + created_at: datetime + updated_at: datetime + + +class LocationWithChildren(LocationResponse): + """Schéma de réponse avec les enfants.""" + + children: list["LocationWithChildren"] = Field(default_factory=list) + + +class LocationWithItemCount(LocationResponse): + """Schéma de réponse avec le nombre d'objets.""" + + item_count: int = Field(default=0, description="Nombre d'objets à cet emplacement") + + +class LocationTree(BaseModel): + """Schéma pour l'arborescence complète des emplacements.""" + + id: int + name: str + type: LocationType + path: str + children: list["LocationTree"] = Field(default_factory=list) + item_count: int = 0 + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..6386fd1 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,142 @@ +[project] +name = "homestock-backend" +version = "0.1.0" +description = "HomeStock - Backend API pour gestion d'inventaire domestique" +authors = [ + { name = "Gilles", email = "gilles@example.com" } +] +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } + +dependencies = [ + # FastAPI + "fastapi>=0.109.0", + "uvicorn[standard]>=0.27.0", + "python-multipart>=0.0.6", # Pour upload fichiers + + # Database + "sqlalchemy>=2.0.25", + "alembic>=1.13.1", + "aiosqlite>=0.19.0", # Async SQLite + + # Validation + "pydantic>=2.5.3", + "pydantic-settings>=2.1.0", + "email-validator>=2.1.0", + + # Logging + "loguru>=0.7.2", + + # Utils + "python-dotenv>=1.0.0", + "httpx>=0.26.0", # Pour tests API +] + +[project.optional-dependencies] +dev = [ + # Testing + "pytest>=7.4.4", + "pytest-asyncio>=0.23.3", + "pytest-cov>=4.1.0", + "pytest-mock>=3.12.0", + + # Linting & Formatting + "ruff>=0.1.14", + "mypy>=1.8.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +# === Ruff Configuration === +[tool.ruff] +target-version = "py311" +line-length = 100 +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long (handled by formatter) + "B008", # do not perform function calls in argument defaults + "C901", # too complex +] + +[tool.ruff.per-file-ignores] +"__init__.py" = ["F401"] # unused imports in __init__ + +[tool.ruff.isort] +known-first-party = ["app"] + +# === MyPy Configuration === +[tool.mypy] +python_version = "3.11" +strict = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_any_generics = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true + +[[tool.mypy.overrides]] +module = [ + "sqlalchemy.*", + "alembic.*", + "loguru.*", +] +ignore_missing_imports = true + +# === Pytest Configuration === +[tool.pytest.ini_options] +minversion = "7.0" +addopts = [ + "-ra", + "--strict-markers", + "--cov=app", + "--cov-report=term-missing", + "--cov-report=html", +] +testpaths = ["tests"] +pythonpath = ["."] +asyncio_mode = "auto" + +# === Coverage Configuration === +[tool.coverage.run] +source = ["app"] +omit = [ + "*/tests/*", + "*/__init__.py", + "*/alembic/*", +] + +[tool.coverage.report] +precision = 2 +show_missing = true +skip_covered = false +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if TYPE_CHECKING:", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", +] diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..4ae4b6b --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,1286 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[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 = "alembic" +version = "1.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/cc/aca263693b2ece99fa99a09b6d092acb89973eb2bb575faef1777e04f8b4/alembic-1.18.1.tar.gz", hash = "sha256:83ac6b81359596816fb3b893099841a0862f2117b2963258e965d70dc62fb866", size = 2044319, upload-time = "2026-01-14T18:53:14.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/36/cd9cb6101e81e39076b2fbe303bfa3c85ca34e55142b0324fcbf22c5c6e2/alembic-1.18.1-py3-none-any.whl", hash = "sha256:f1c3b0920b87134e851c25f1f7f236d8a332c34b75416802d06971df5d1b7810", size = 260973, upload-time = "2026-01-14T18:53:17.533Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[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 = "coverage" +version = "7.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/49/349848445b0e53660e258acbcc9b0d014895b6739237920886672240f84b/coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3", size = 826523, upload-time = "2026-01-25T13:00:04.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/01/abca50583a8975bb6e1c59eff67ed8e48bb127c07dad5c28d9e96ccc09ec/coverage-7.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e", size = 218971, upload-time = "2026-01-25T12:57:36.953Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0e/b6489f344d99cd1e5b4d5e1be52dfd3f8a3dc5112aa6c33948da8cabad4e/coverage-7.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e", size = 219473, upload-time = "2026-01-25T12:57:38.934Z" }, + { url = "https://files.pythonhosted.org/packages/17/11/db2f414915a8e4ec53f60b17956c27f21fb68fcf20f8a455ce7c2ccec638/coverage-7.13.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b780090d15fd58f07cf2011943e25a5f0c1c894384b13a216b6c86c8a8a7c508", size = 249896, upload-time = "2026-01-25T12:57:40.365Z" }, + { url = "https://files.pythonhosted.org/packages/80/06/0823fe93913663c017e508e8810c998c8ebd3ec2a5a85d2c3754297bdede/coverage-7.13.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b", size = 251810, upload-time = "2026-01-25T12:57:42.045Z" }, + { url = "https://files.pythonhosted.org/packages/61/dc/b151c3cc41b28cdf7f0166c5fa1271cbc305a8ec0124cce4b04f74791a18/coverage-7.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6326e18e9a553e674d948536a04a80d850a5eeefe2aae2e6d7cf05d54046c01b", size = 253920, upload-time = "2026-01-25T12:57:44.026Z" }, + { url = "https://files.pythonhosted.org/packages/2d/35/e83de0556e54a4729a2b94ea816f74ce08732e81945024adee46851c2264/coverage-7.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f", size = 250025, upload-time = "2026-01-25T12:57:45.624Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/af2eb9c3926ce3ea0d58a0d2516fcbdacf7a9fc9559fe63076beaf3f2596/coverage-7.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3", size = 251612, upload-time = "2026-01-25T12:57:47.713Z" }, + { url = "https://files.pythonhosted.org/packages/26/62/5be2e25f3d6c711d23b71296f8b44c978d4c8b4e5b26871abfc164297502/coverage-7.13.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b", size = 249670, upload-time = "2026-01-25T12:57:49.378Z" }, + { url = "https://files.pythonhosted.org/packages/b3/51/400d1b09a8344199f9b6a6fc1868005d766b7ea95e7882e494fa862ca69c/coverage-7.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1", size = 249395, upload-time = "2026-01-25T12:57:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/e0/36/f02234bc6e5230e2f0a63fd125d0a2093c73ef20fdf681c7af62a140e4e7/coverage-7.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059", size = 250298, upload-time = "2026-01-25T12:57:52.287Z" }, + { url = "https://files.pythonhosted.org/packages/b0/06/713110d3dd3151b93611c9cbfc65c15b4156b44f927fced49ac0b20b32a4/coverage-7.13.2-cp311-cp311-win32.whl", hash = "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031", size = 221485, upload-time = "2026-01-25T12:57:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/16/0c/3ae6255fa1ebcb7dec19c9a59e85ef5f34566d1265c70af5b2fc981da834/coverage-7.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e", size = 222421, upload-time = "2026-01-25T12:57:55.433Z" }, + { url = "https://files.pythonhosted.org/packages/b5/37/fabc3179af4d61d89ea47bd04333fec735cd5e8b59baad44fed9fc4170d7/coverage-7.13.2-cp311-cp311-win_arm64.whl", hash = "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28", size = 221088, upload-time = "2026-01-25T12:57:57.41Z" }, + { url = "https://files.pythonhosted.org/packages/46/39/e92a35f7800222d3f7b2cbb7bbc3b65672ae8d501cb31801b2d2bd7acdf1/coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d", size = 219142, upload-time = "2026-01-25T12:58:00.448Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/8bf9e9309c4c996e65c52a7c5a112707ecdd9fbaf49e10b5a705a402bbb4/coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3", size = 219503, upload-time = "2026-01-25T12:58:02.451Z" }, + { url = "https://files.pythonhosted.org/packages/87/93/17661e06b7b37580923f3f12406ac91d78aeed293fb6da0b69cc7957582f/coverage-7.13.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99", size = 251006, upload-time = "2026-01-25T12:58:04.059Z" }, + { url = "https://files.pythonhosted.org/packages/12/f0/f9e59fb8c310171497f379e25db060abef9fa605e09d63157eebec102676/coverage-7.13.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f", size = 253750, upload-time = "2026-01-25T12:58:05.574Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b1/1935e31add2232663cf7edd8269548b122a7d100047ff93475dbaaae673e/coverage-7.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f", size = 254862, upload-time = "2026-01-25T12:58:07.647Z" }, + { url = "https://files.pythonhosted.org/packages/af/59/b5e97071ec13df5f45da2b3391b6cdbec78ba20757bc92580a5b3d5fa53c/coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa", size = 251420, upload-time = "2026-01-25T12:58:09.309Z" }, + { url = "https://files.pythonhosted.org/packages/3f/75/9495932f87469d013dc515fb0ce1aac5fa97766f38f6b1a1deb1ee7b7f3a/coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce", size = 252786, upload-time = "2026-01-25T12:58:10.909Z" }, + { url = "https://files.pythonhosted.org/packages/6a/59/af550721f0eb62f46f7b8cb7e6f1860592189267b1c411a4e3a057caacee/coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94", size = 250928, upload-time = "2026-01-25T12:58:12.449Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b1/21b4445709aae500be4ab43bbcfb4e53dc0811c3396dcb11bf9f23fd0226/coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5", size = 250496, upload-time = "2026-01-25T12:58:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/0f5d89dfe0392990e4f3980adbde3eb34885bc1effb2dc369e0bf385e389/coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b", size = 252373, upload-time = "2026-01-25T12:58:15.976Z" }, + { url = "https://files.pythonhosted.org/packages/01/c9/0cf1a6a57a9968cc049a6b896693faa523c638a5314b1fc374eb2b2ac904/coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41", size = 221696, upload-time = "2026-01-25T12:58:17.517Z" }, + { url = "https://files.pythonhosted.org/packages/4d/05/d7540bf983f09d32803911afed135524570f8c47bb394bf6206c1dc3a786/coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e", size = 222504, upload-time = "2026-01-25T12:58:19.115Z" }, + { url = "https://files.pythonhosted.org/packages/15/8b/1a9f037a736ced0a12aacf6330cdaad5008081142a7070bc58b0f7930cbc/coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894", size = 221120, upload-time = "2026-01-25T12:58:21.334Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f0/3d3eac7568ab6096ff23791a526b0048a1ff3f49d0e236b2af6fb6558e88/coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6", size = 219168, upload-time = "2026-01-25T12:58:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a6/f8b5cfeddbab95fdef4dcd682d82e5dcff7a112ced57a959f89537ee9995/coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc", size = 219537, upload-time = "2026-01-25T12:58:24.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e6/8d8e6e0c516c838229d1e41cadcec91745f4b1031d4db17ce0043a0423b4/coverage-7.13.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f", size = 250528, upload-time = "2026-01-25T12:58:26.567Z" }, + { url = "https://files.pythonhosted.org/packages/8e/78/befa6640f74092b86961f957f26504c8fba3d7da57cc2ab7407391870495/coverage-7.13.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1", size = 253132, upload-time = "2026-01-25T12:58:28.251Z" }, + { url = "https://files.pythonhosted.org/packages/9d/10/1630db1edd8ce675124a2ee0f7becc603d2bb7b345c2387b4b95c6907094/coverage-7.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9", size = 254374, upload-time = "2026-01-25T12:58:30.294Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1d/0d9381647b1e8e6d310ac4140be9c428a0277330991e0c35bdd751e338a4/coverage-7.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c", size = 250762, upload-time = "2026-01-25T12:58:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5636dfc9a7c871ee8776af83ee33b4c26bc508ad6cee1e89b6419a366582/coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5", size = 252502, upload-time = "2026-01-25T12:58:33.961Z" }, + { url = "https://files.pythonhosted.org/packages/02/2a/7ff2884d79d420cbb2d12fed6fff727b6d0ef27253140d3cdbbd03187ee0/coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4", size = 250463, upload-time = "2026-01-25T12:58:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/ba51087db645b6c7261570400fc62c89a16278763f36ba618dc8657a187b/coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c", size = 250288, upload-time = "2026-01-25T12:58:37.226Z" }, + { url = "https://files.pythonhosted.org/packages/03/07/44e6f428551c4d9faf63ebcefe49b30e5c89d1be96f6a3abd86a52da9d15/coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31", size = 252063, upload-time = "2026-01-25T12:58:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/c2/67/35b730ad7e1859dd57e834d1bc06080d22d2f87457d53f692fce3f24a5a9/coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8", size = 221716, upload-time = "2026-01-25T12:58:40.484Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/e5fcf5a97c72f45fc14829237a6550bf49d0ab882ac90e04b12a69db76b4/coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb", size = 222522, upload-time = "2026-01-25T12:58:43.247Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/25d7b2f946d239dd2d6644ca2cc060d24f97551e2af13b6c24c722ae5f97/coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557", size = 221145, upload-time = "2026-01-25T12:58:45Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f7/080376c029c8f76fadfe43911d0daffa0cbdc9f9418a0eead70c56fb7f4b/coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e", size = 219861, upload-time = "2026-01-25T12:58:46.586Z" }, + { url = "https://files.pythonhosted.org/packages/42/11/0b5e315af5ab35f4c4a70e64d3314e4eec25eefc6dec13be3a7d5ffe8ac5/coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7", size = 220207, upload-time = "2026-01-25T12:58:48.277Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0c/0874d0318fb1062117acbef06a09cf8b63f3060c22265adaad24b36306b7/coverage-7.13.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3", size = 261504, upload-time = "2026-01-25T12:58:49.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/5e/1cd72c22ecb30751e43a72f40ba50fcef1b7e93e3ea823bd9feda8e51f9a/coverage-7.13.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3", size = 263582, upload-time = "2026-01-25T12:58:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/8acf356707c7a42df4d0657020308e23e5a07397e81492640c186268497c/coverage-7.13.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421", size = 266008, upload-time = "2026-01-25T12:58:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/41/41/ea1730af99960309423c6ea8d6a4f1fa5564b2d97bd1d29dda4b42611f04/coverage-7.13.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5", size = 260762, upload-time = "2026-01-25T12:58:55.372Z" }, + { url = "https://files.pythonhosted.org/packages/22/fa/02884d2080ba71db64fdc127b311db60e01fe6ba797d9c8363725e39f4d5/coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23", size = 263571, upload-time = "2026-01-25T12:58:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6b/4083aaaeba9b3112f55ac57c2ce7001dc4d8fa3fcc228a39f09cc84ede27/coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c", size = 261200, upload-time = "2026-01-25T12:58:59.255Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d2/aea92fa36d61955e8c416ede9cf9bf142aa196f3aea214bb67f85235a050/coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f", size = 260095, upload-time = "2026-01-25T12:59:01.066Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ae/04ffe96a80f107ea21b22b2367175c621da920063260a1c22f9452fd7866/coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573", size = 262284, upload-time = "2026-01-25T12:59:02.802Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7a/6f354dcd7dfc41297791d6fb4e0d618acb55810bde2c1fd14b3939e05c2b/coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343", size = 222389, upload-time = "2026-01-25T12:59:04.563Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d5/080ad292a4a3d3daf411574be0a1f56d6dee2c4fdf6b005342be9fac807f/coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47", size = 223450, upload-time = "2026-01-25T12:59:06.677Z" }, + { url = "https://files.pythonhosted.org/packages/88/96/df576fbacc522e9fb8d1c4b7a7fc62eb734be56e2cba1d88d2eabe08ea3f/coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7", size = 221707, upload-time = "2026-01-25T12:59:08.363Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/1da9e51a0775634b04fcc11eb25c002fc58ee4f92ce2e8512f94ac5fc5bf/coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef", size = 219213, upload-time = "2026-01-25T12:59:11.909Z" }, + { url = "https://files.pythonhosted.org/packages/46/35/b3caac3ebbd10230fea5a33012b27d19e999a17c9285c4228b4b2e35b7da/coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f", size = 219549, upload-time = "2026-01-25T12:59:13.638Z" }, + { url = "https://files.pythonhosted.org/packages/76/9c/e1cf7def1bdc72c1907e60703983a588f9558434a2ff94615747bd73c192/coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5", size = 250586, upload-time = "2026-01-25T12:59:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ba/49/f54ec02ed12be66c8d8897270505759e057b0c68564a65c429ccdd1f139e/coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4", size = 253093, upload-time = "2026-01-25T12:59:17.491Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5e/aaf86be3e181d907e23c0f61fccaeb38de8e6f6b47aed92bf57d8fc9c034/coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27", size = 254446, upload-time = "2026-01-25T12:59:19.752Z" }, + { url = "https://files.pythonhosted.org/packages/28/c8/a5fa01460e2d75b0c853b392080d6829d3ca8b5ab31e158fa0501bc7c708/coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548", size = 250615, upload-time = "2026-01-25T12:59:21.928Z" }, + { url = "https://files.pythonhosted.org/packages/86/0b/6d56315a55f7062bb66410732c24879ccb2ec527ab6630246de5fe45a1df/coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660", size = 252452, upload-time = "2026-01-25T12:59:23.592Z" }, + { url = "https://files.pythonhosted.org/packages/30/19/9bc550363ebc6b0ea121977ee44d05ecd1e8bf79018b8444f1028701c563/coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92", size = 250418, upload-time = "2026-01-25T12:59:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/580530a31ca2f0cc6f07a8f2ab5460785b02bb11bdf815d4c4d37a4c5169/coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82", size = 250231, upload-time = "2026-01-25T12:59:27.888Z" }, + { url = "https://files.pythonhosted.org/packages/e2/42/dd9093f919dc3088cb472893651884bd675e3df3d38a43f9053656dca9a2/coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892", size = 251888, upload-time = "2026-01-25T12:59:29.636Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a6/0af4053e6e819774626e133c3d6f70fae4d44884bfc4b126cb647baee8d3/coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe", size = 221968, upload-time = "2026-01-25T12:59:31.424Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/5aff1e1f80d55862442855517bb8ad8ad3a68639441ff6287dde6a58558b/coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859", size = 222783, upload-time = "2026-01-25T12:59:33.118Z" }, + { url = "https://files.pythonhosted.org/packages/de/20/09abafb24f84b3292cc658728803416c15b79f9ee5e68d25238a895b07d9/coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6", size = 221348, upload-time = "2026-01-25T12:59:34.939Z" }, + { url = "https://files.pythonhosted.org/packages/b6/60/a3820c7232db63be060e4019017cd3426751c2699dab3c62819cdbcea387/coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b", size = 219950, upload-time = "2026-01-25T12:59:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/fd/37/e4ef5975fdeb86b1e56db9a82f41b032e3d93a840ebaf4064f39e770d5c5/coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417", size = 220209, upload-time = "2026-01-25T12:59:38.339Z" }, + { url = "https://files.pythonhosted.org/packages/54/df/d40e091d00c51adca1e251d3b60a8b464112efa3004949e96a74d7c19a64/coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee", size = 261576, upload-time = "2026-01-25T12:59:40.446Z" }, + { url = "https://files.pythonhosted.org/packages/c5/44/5259c4bed54e3392e5c176121af9f71919d96dde853386e7730e705f3520/coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1", size = 263704, upload-time = "2026-01-25T12:59:42.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/ae9f005827abcbe2c70157459ae86053971c9fa14617b63903abbdce26d9/coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d", size = 266109, upload-time = "2026-01-25T12:59:44.073Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c0/8e279c1c0f5b1eaa3ad9b0fb7a5637fc0379ea7d85a781c0fe0bb3cfc2ab/coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6", size = 260686, upload-time = "2026-01-25T12:59:45.804Z" }, + { url = "https://files.pythonhosted.org/packages/b2/47/3a8112627e9d863e7cddd72894171c929e94491a597811725befdcd76bce/coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a", size = 263568, upload-time = "2026-01-25T12:59:47.929Z" }, + { url = "https://files.pythonhosted.org/packages/92/bc/7ea367d84afa3120afc3ce6de294fd2dcd33b51e2e7fbe4bbfd200f2cb8c/coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04", size = 261174, upload-time = "2026-01-25T12:59:49.717Z" }, + { url = "https://files.pythonhosted.org/packages/33/b7/f1092dcecb6637e31cc2db099581ee5c61a17647849bae6b8261a2b78430/coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f", size = 260017, upload-time = "2026-01-25T12:59:51.463Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/f3d07d4b95fbe1a2ef0958c15da614f7e4f557720132de34d2dc3aa7e911/coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f", size = 262337, upload-time = "2026-01-25T12:59:53.407Z" }, + { url = "https://files.pythonhosted.org/packages/e0/db/b0d5b2873a07cb1e06a55d998697c0a5a540dcefbf353774c99eb3874513/coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3", size = 222749, upload-time = "2026-01-25T12:59:56.316Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2f/838a5394c082ac57d85f57f6aba53093b30d9089781df72412126505716f/coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba", size = 223857, upload-time = "2026-01-25T12:59:58.201Z" }, + { url = "https://files.pythonhosted.org/packages/44/d4/b608243e76ead3a4298824b50922b89ef793e50069ce30316a65c1b4d7ef/coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c", size = 221881, upload-time = "2026-01-25T13:00:00.449Z" }, + { url = "https://files.pythonhosted.org/packages/d2/db/d291e30fdf7ea617a335531e72294e0c723356d7fdde8fba00610a76bda9/coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5", size = 210943, upload-time = "2026-01-25T13:00:02.388Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.128.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" }, + { url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" }, + { url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" }, + { url = "https://files.pythonhosted.org/packages/1f/54/dcf9f737b96606f82f8dd05becfb8d238db0633dd7397d542a296fe9cad3/greenlet-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:32e4ca9777c5addcbf42ff3915d99030d8e00173a56f80001fb3875998fe410b", size = 226462, upload-time = "2026-01-23T15:36:50.422Z" }, + { url = "https://files.pythonhosted.org/packages/91/37/61e1015cf944ddd2337447d8e97fb423ac9bc21f9963fb5f206b53d65649/greenlet-3.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:da19609432f353fed186cc1b85e9440db93d489f198b4bdf42ae19cc9d9ac9b4", size = 225715, upload-time = "2026-01-23T15:33:17.298Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, + { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, + { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/34/2f/5e0e41f33c69655300a5e54aeb637cf8ff57f1786a3aba374eacc0228c1d/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a", size = 227156, upload-time = "2026-01-23T15:34:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, + { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, + { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, + { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" }, + { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" }, + { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, + { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, + { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, + { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, +] + +[[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 = "homestock-backend" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiosqlite" }, + { name = "alembic" }, + { name = "email-validator" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "loguru" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "sqlalchemy" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosqlite", specifier = ">=0.19.0" }, + { name = "alembic", specifier = ">=1.13.1" }, + { name = "email-validator", specifier = ">=2.1.0" }, + { name = "fastapi", specifier = ">=0.109.0" }, + { name = "httpx", specifier = ">=0.26.0" }, + { name = "loguru", specifier = ">=0.7.2" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" }, + { name = "pydantic", specifier = ">=2.5.3" }, + { name = "pydantic-settings", specifier = ">=2.1.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.4" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.3" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "python-multipart", specifier = ">=0.0.6" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.14" }, + { name = "sqlalchemy", specifier = ">=2.0.25" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" }, +] +provides-extras = ["dev"] + +[[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.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[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.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[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 = "librt" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, + { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, + { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, + { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, + { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[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/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.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/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[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/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.46" +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/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/ac/b42ad16800d0885105b59380ad69aad0cce5a65276e269ce2729a2343b6a/sqlalchemy-2.0.46-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:261c4b1f101b4a411154f1da2b76497d73abbfc42740029205d4d01fa1052684", size = 2154851, upload-time = "2026-01-21T18:27:30.54Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/d8710068cb79f64d002ebed62a7263c00c8fd95f4ebd4b5be8f7ca93f2bc/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181903fe8c1b9082995325f1b2e84ac078b1189e2819380c2303a5f90e114a62", size = 3311241, upload-time = "2026-01-21T18:32:33.45Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/20c71487c7219ab3aa7421c7c62d93824c97c1460f2e8bb72404b0192d13/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:590be24e20e2424a4c3c1b0835e9405fa3d0af5823a1a9fc02e5dff56471515f", size = 3310741, upload-time = "2026-01-21T18:44:57.887Z" }, + { url = "https://files.pythonhosted.org/packages/65/80/d26d00b3b249ae000eee4db206fcfc564bf6ca5030e4747adf451f4b5108/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7568fe771f974abadce52669ef3a03150ff03186d8eb82613bc8adc435a03f01", size = 3263116, upload-time = "2026-01-21T18:32:35.044Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/74dda7506640923821340541e8e45bd3edd8df78664f1f2e0aae8077192b/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf7e1e78af38047e08836d33502c7a278915698b7c2145d045f780201679999", size = 3285327, upload-time = "2026-01-21T18:44:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/9f/25/6dcf8abafff1389a21c7185364de145107b7394ecdcb05233815b236330d/sqlalchemy-2.0.46-cp311-cp311-win32.whl", hash = "sha256:9d80ea2ac519c364a7286e8d765d6cd08648f5b21ca855a8017d9871f075542d", size = 2114564, upload-time = "2026-01-21T18:33:15.85Z" }, + { url = "https://files.pythonhosted.org/packages/93/5f/e081490f8523adc0088f777e4ebad3cac21e498ec8a3d4067074e21447a1/sqlalchemy-2.0.46-cp311-cp311-win_amd64.whl", hash = "sha256:585af6afe518732d9ccd3aea33af2edaae4a7aa881af5d8f6f4fe3a368699597", size = 2139233, upload-time = "2026-01-21T18:33:17.528Z" }, + { url = "https://files.pythonhosted.org/packages/b6/35/d16bfa235c8b7caba3730bba43e20b1e376d2224f407c178fbf59559f23e/sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a9a72b0da8387f15d5810f1facca8f879de9b85af8c645138cba61ea147968c", size = 2153405, upload-time = "2026-01-21T19:05:54.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/6c/3192e24486749862f495ddc6584ed730c0c994a67550ec395d872a2ad650/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2347c3f0efc4de367ba00218e0ae5c4ba2306e47216ef80d6e31761ac97cb0b9", size = 3334702, upload-time = "2026-01-21T18:46:45.384Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a2/b9f33c8d68a3747d972a0bb758c6b63691f8fb8a49014bc3379ba15d4274/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9094c8b3197db12aa6f05c51c05daaad0a92b8c9af5388569847b03b1007fb1b", size = 3347664, upload-time = "2026-01-21T18:40:09.979Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d2/3e59e2a91eaec9db7e8dc6b37b91489b5caeb054f670f32c95bcba98940f/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37fee2164cf21417478b6a906adc1a91d69ae9aba8f9533e67ce882f4bb1de53", size = 3277372, upload-time = "2026-01-21T18:46:47.168Z" }, + { url = "https://files.pythonhosted.org/packages/dd/dd/67bc2e368b524e2192c3927b423798deda72c003e73a1e94c21e74b20a85/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1e14b2f6965a685c7128bd315e27387205429c2e339eeec55cb75ca4ab0ea2e", size = 3312425, upload-time = "2026-01-21T18:40:11.548Z" }, + { url = "https://files.pythonhosted.org/packages/43/82/0ecd68e172bfe62247e96cb47867c2d68752566811a4e8c9d8f6e7c38a65/sqlalchemy-2.0.46-cp312-cp312-win32.whl", hash = "sha256:412f26bb4ba942d52016edc8d12fb15d91d3cd46b0047ba46e424213ad407bcb", size = 2113155, upload-time = "2026-01-21T18:42:49.748Z" }, + { url = "https://files.pythonhosted.org/packages/bc/2a/2821a45742073fc0331dc132552b30de68ba9563230853437cac54b2b53e/sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl", hash = "sha256:ea3cd46b6713a10216323cda3333514944e510aa691c945334713fca6b5279ff", size = 2140078, upload-time = "2026-01-21T18:42:51.197Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" }, + { url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" }, + { url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" }, + { url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" }, + { url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" }, + { url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" }, + { url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" }, + { url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" }, + { url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" }, + { url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" }, + { url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" }, + { url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { 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/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { 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.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] diff --git a/contracts/data_model.md b/contracts/data_model.md index 6b795f5..a026015 100644 --- a/contracts/data_model.md +++ b/contracts/data_model.md @@ -10,15 +10,33 @@ Décrit les entités clés et leurs relations. --- ## Entités principales -- Entité : -- Champs : -- Contraintes : + +### Item (objet d'inventaire) +- Champs : `id` (PK), `name` (string, requis), `description` (text, optionnel), `quantity` (int, défaut 1), `price` (decimal, optionnel), `purchase_date` (date, optionnel), `status` (enum: in_stock/in_use/broken/sold, défaut in_stock), `location_id` (FK Location), `category_id` (FK Category), `created_at`, `updated_at` +- Contraintes : `name` unique par location, `quantity` >= 0, `price` >= 0, index sur `name` + `status` pour recherche + +### Location (emplacement physique hiérarchique) +- Champs : `id` (PK), `name` (string, requis), `type` (enum: room/furniture/drawer/box), `parent_id` (FK Location, self-reference, optionnel), `path` (string, chemin complet calculé), `created_at`, `updated_at` +- Contraintes : `name` unique par `parent_id`, pas de cycle dans hiérarchie (validation applicative), index sur `path` pour recherche hiérarchique + +### Category (domaine/catégorie) +- Champs : `id` (PK), `name` (string, requis, unique), `slug` (string, requis, unique), `description` (text, optionnel), `color` (string hex, optionnel), `icon` (string, optionnel), `created_at`, `updated_at` +- Contraintes : `name` et `slug` uniques, slug kebab-case, catégories prédéfinies (bricolage, informatique, électronique, cuisine) + possibilité ajout + +### Document (fichier attaché) +- Champs : `id` (PK), `item_id` (FK Item, requis), `type` (enum: photo/notice/invoice/other), `filename` (string, requis), `filepath` (string, chemin relatif dans uploads/), `mime_type` (string), `size_bytes` (int), `uploaded_at` +- Contraintes : `filepath` unique, index sur `item_id`, taille max 50MB par fichier, types MIME autorisés (images, PDF) ## Relations -- Relation : -- Cardinalité : ---- +- **Item N..1 Location** : Un item est dans une location, une location contient plusieurs items (CASCADE DELETE optionnel) +- **Item N..1 Category** : Un item appartient à une catégorie, une catégorie contient plusieurs items (RESTRICT DELETE) +- **Item 1..N Document** : Un item a plusieurs documents, un document appartient à un item (CASCADE DELETE) +- **Location N..1 Location (self)** : Hiérarchie parent/enfant (garage → étagère → boîte), `parent_id` NULL pour racine (RESTRICT DELETE pour éviter orphelins) -## Exemple (a supprimer) -- `User` 1..N `Order`. +## Indexation recherche (SQLite FTS5) + +- **fts_items** : Table virtuelle FTS5 sur `Item.name` + `Item.description` + `Category.name` + `Location.path` pour recherche full-text performante +- Synchronisation : Triggers SQLite pour maintenir FTS5 à jour lors des INSERT/UPDATE/DELETE sur Item + +--- \ No newline at end of file diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml index e69de29..6f73386 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +services: + # Backend FastAPI + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: homestock-backend + ports: + - "${BACKEND_EXTERNAL_PORT}:8000" + volumes: + - ./backend:/app + - ./data:/app/data + - ./uploads:/app/uploads + environment: + - APP_NAME=${APP_NAME} + - ENVIRONMENT=${ENVIRONMENT} + - DEBUG=${DEBUG} + - LOG_LEVEL=${LOG_LEVEL} + - DATABASE_URL=${DATABASE_URL} + - CORS_ORIGINS=${CORS_ORIGINS} + - UPLOAD_DIR=${UPLOAD_DIR} + - MAX_UPLOAD_SIZE_MB=${MAX_UPLOAD_SIZE_MB} + restart: unless-stopped + command: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + + # Frontend React + Vite + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: homestock-frontend + ports: + - "${FRONTEND_EXTERNAL_PORT}:5173" + volumes: + - ./frontend:/app + - /app/node_modules + environment: + - VITE_API_BASE_URL=${VITE_API_BASE_URL} + - VITE_APP_NAME=${VITE_APP_NAME} + - VITE_APP_VERSION=${VITE_APP_VERSION} + restart: unless-stopped + depends_on: + - backend + command: npm run dev -- --host 0.0.0.0 + +volumes: + data: + uploads: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1487142..e8276a6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -11,54 +11,49 @@ Il sert de base aux décisions techniques, aux ADR et au découpage des tâches. --- -## 1. Vue d’ensemble -- Objectif produit : (exemple: améliorer la traçabilité — a supprimer) -- Type d’app (web, mobile, API) : (exemple: à personnaliser — a supprimer) -- Contraintes fortes : (exemple: déploiement on-premise — a supprimer) +## 1. Vue d'ensemble +- Objectif produit : Gérer l'inventaire complet d'un domicile avec recherche, localisation précise et archivage de documents +- Type d'app : Application web full-stack (API REST + SPA) avec déploiement conteneurisé +- Contraintes fortes : Self-hosted sur réseau local, mono-utilisateur, simplicité de déploiement et maintenance -## 2. Principes d’architecture -- Principes non négociables : (exemple: à personnaliser — a supprimer) -- Principes d’évolution : -- Qualités prioritaires (performance, sécurité, scalabilité) : (exemple: sécurité et performance — a supprimer) +## 2. Principes d'architecture +- Principes non négociables : Monolithe modulaire, séparation stricte frontend/backend, documentation avant implémentation, ADR pour décisions structurantes +- Principes d'évolution : Architecture permettant une évolution vers multi-utilisateurs sans refonte complète, modules indépendants testables séparément +- Qualités prioritaires : Simplicité d'utilisation > Performance > Maintenabilité > Évolutivité (mono-utilisateur donc pas de scalabilité horizontale) ## 3. Architecture logique -- Modules principaux : -- Responsabilités par module : -- Frontend/Backend séparation : +- Modules principaux : `items` (objets/équipements), `locations` (emplacements physiques), `categories` (domaines: bricolage, informatique, etc.), `documents` (photos, notices, factures), `search` (recherche full-text) +- Responsabilités par module : `items` = CRUD objets + état + prix, `locations` = hiérarchie lieux (meuble>tiroir>boîte), `categories` = classification, `documents` = upload/stockage fichiers, `search` = indexation et recherche +- Frontend/Backend séparation : SPA React (frontend) communique uniquement via API REST avec FastAPI (backend), pas de SSR (Server-Side Rendering), API documentée par OpenAPI ## 4. Architecture technique -- Langages & frameworks : -- Base de données : -- Stockage fichiers : -- Infra cible (cloud/self-hosted) : (exemple: self-hosted — a supprimer) +- Langages & frameworks : Backend = Python 3.11+ avec FastAPI + SQLAlchemy + Pydantic, Frontend = TypeScript + React 18+ + Vite + TailwindCSS +- Base de données : SQLite (fichier local homestock.db) avec FTS5 pour recherche full-text, migrations via Alembic +- Stockage fichiers : Système de fichiers local, dossier `uploads/` organisé par type (photos/, notices/, factures/), chemin relatif stocké en BDD +- Infra cible : Self-hosted via Docker Compose, réseau local 10.0.0.0/22, reverse proxy optionnel (Traefik/Nginx) ## 5. Flux de données -- Flux principaux (lecture/écriture) : -- Intégrations externes : (exemple: ERP existant — a supprimer) -- Gestion des événements/asynchronisme : +- Flux principaux : Lecture = GET items avec filtres (catégorie, localisation, état) + recherche full-text, Écriture = POST/PUT/DELETE items + upload fichiers multipart/form-data +- Intégrations externes : Aucune intégration externe prévue, système autonome et déconnecté +- Gestion des événements/asynchronisme : Upload fichiers en asynchrone (FastAPI background tasks), pas d'événements temps réel pour MVP (possibilité WebSocket future) ## 6. Sécurité -- Authentification/autorisation : -- Données sensibles : (exemple: emails + historiques de paiement — a supprimer) -- Traçabilité/audit : +- Authentification/autorisation : Optionnelle pour MVP mono-utilisateur, si activée = login simple (username/password) + session cookie, pas de JWT pour simplicité +- Données sensibles : Factures (montants, dates d'achat), localisations précises d'objets de valeur → chiffrement au repos optionnel, accès réseau local uniquement +- Traçabilité/audit : Pas d'audit trail strict pour MVP, timestamps created_at/updated_at sur entités principales, logs applicatifs pour debug ## 7. Observabilité -- Logs (journaux) : -- Metrics (mesures) : -- Alerting (alertes) : +- Logs : Python logging avec rotation (loguru recommandé), niveau INFO en production, DEBUG en dev, format JSON pour parsing +- Metrics : Optionnelles pour MVP, possibilité ajout Prometheus + Grafana plus tard (nb items, espace disque uploads/, temps réponse API) +- Alerting : Pas d'alerting pour MVP mono-utilisateur, monitoring manuel via logs et health check endpoint `/health` ## 8. Conventions de code -- Organisation des dossiers : -- Standards de code : -- Tests obligatoires : +- Organisation des dossiers : Backend = `backend/app/` (routers/, models/, schemas/, services/), Frontend = `frontend/src/` (components/, pages/, hooks/, api/) +- Standards de code : Backend = ruff format + mypy strict, Frontend = ESLint + Prettier, nommage snake_case (Python) et camelCase (TS), français pour commentaires +- Tests obligatoires : Backend = tests unitaires (services/) + tests intégration (API), Frontend = tests unitaires (utils/hooks), couverture minimum 70% sur logique métier ## 9. Évolution & dette -- Zones à risque : (exemple: montée en charge — a supprimer) -- Améliorations prévues : (exemple: reporting avancé — a supprimer) +- Zones à risque : Recherche full-text sur SQLite (limitations si >10k items), stockage fichiers local (backup manuel nécessaire), authentification simpliste +- Améliorations prévues : Migration SQLite→PostgreSQL si besoin, stockage fichiers vers MinIO/S3, multi-utilisateurs avec RBAC (contrôle d'accès par rôle), API mobile, import/export CSV ---- - -## Exemple (a supprimer) -- Modules : `auth`, `users`, `billing`, `catalog`. -- DB : PostgreSQL + migrations. -- Auth : JWT (jeton) + RBAC (contrôle d’accès par rôle). +--- \ No newline at end of file diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index 2f4e5fd..fcfe964 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -11,24 +11,20 @@ Ce guide définit les conventions de code et de documentation. --- ## 1. Nommage -- Variables / fonctions : -- Fichiers / dossiers : -- API endpoints : +- Variables / fonctions : Backend = snake_case (Python PEP8), Frontend = camelCase (TypeScript/JavaScript standard) +- Fichiers / dossiers : Backend = snake_case (item_service.py), Frontend = PascalCase pour composants (ItemCard.tsx), kebab-case pour autres (use-items.ts) +- API endpoints : REST kebab-case `/api/v1/items`, `/api/v1/item-categories`, pluriel pour collections, singulier pour ressource unique ## 2. Formatage -- Formatter / linter : -- Règles principales : +- Formatter / linter : Backend = ruff (format + lint) + mypy (types), Frontend = Prettier + ESLint + TypeScript strict +- Règles principales : Indentation 4 espaces (Python), 2 espaces (TS/JS), ligne max 100 caractères, trailing commas, quotes doubles ## 3. Tests -- Nommage des tests : -- Structure des tests : +- Nommage des tests : Backend = `test__.py`, Frontend = `.test.ts`, fonctions de test descriptives `test_create_item_with_valid_data` +- Structure des tests : Pattern AAA (Arrange/Act/Assert), fixtures pytest pour setup, mocks minimaux, tests isolés et reproductibles ## 4. Documentation -- Doc obligatoire : (exemple: README + ARCHITECTURE — a supprimer) -- ADR (Architecture Decision Record) : +- Doc obligatoire : README.md (démarrage), ARCHITECTURE.md (structure technique), fichiers CONTEXT (backend/frontend), ADR pour décisions structurantes +- ADR : Suivre template docs/adr/TEMPLATE.md, numérotation séquentielle (0001, 0002...), statut (proposé/accepté/obsolète), contexte + décision + conséquences ---- - -## Exemple (a supprimer) -- Formatter : `prettier` + `eslint`. -- Tests : `feature_x.test.ts`. +--- \ No newline at end of file diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 57c4887..11cb37a 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -12,36 +12,36 @@ Il sert de référence aux agents et aux contributeurs. --- ## 1. Branches -- Convention de nommage : -- Branches protégées : (exemple: main — a supprimer) -- Politique de merge : +- Convention de nommage : `feature/REQ-XXX-description`, `fix/bug-description`, `docs/update-xxx`, `refactor/module-name` +- Branches protégées : `main` (production-ready) +- Politique de merge : Squash merge vers main, fast-forward interdit, historique linéaire privilégié ## 2. Commits -- Convention (ex: conventional commits) : -- Granularité attendue : (exemple: 1 feature par PR — a supprimer) +- Convention : Conventional Commits (feat/fix/docs/refactor/test/chore), format: `type(scope): message` en français +- Granularité attendue : 1 commit par changement logique, 1 feature complète par PR avec tests associés ## 3. Pull Requests -- Template PR : -- Relectures requises : (exemple: 1 review — a supprimer) +- Template PR : Titre court (<70 car), description avec ## Changements, ## Tests, ## Checklist, référence REQ-XXX +- Relectures requises : Aucune (projet solo), possibilité de review par Claude Code avant merge - Checklist obligatoire : `docs/PR_CHECKLIST.md` ## 4. CI/CD -- Pipeline minimal (lint/test/build) : -- Vérifications bloquantes : +- Pipeline minimal : Gitea Actions avec steps: lint (ruff, eslint) → test (pytest, vitest) → build (Docker images) → optionnel deploy +- Vérifications bloquantes : Lint sans erreur, tests unitaires passent, build Docker réussit, couverture >70% sur nouveau code ## 5. Releases -- Versioning (semver = versionnage sémantique) : -- Tagging : +- Versioning : Semver (semantic versioning) v0.x.y pour pré-release, v1.0.0 pour première version stable, tags Git annotés +- Tagging : Tag après merge dans main, format `vX.Y.Z`, signé GPG si possible - Release notes : `product/RELEASE_NOTES.md` ## 6. Qualité -- Definition of Done (définition de terminé) : (exemple: tests + doc — a supprimer) -- Tests obligatoires : -- Mises à jour doc : (exemple: si impact sur l’API — a supprimer) +- Definition of Done : Feature implémentée + tests unitaires + tests intégration API + documentation mise à jour (si impact) + CI passe +- Tests obligatoires : Tests unitaires sur logique métier (services/), tests intégration sur endpoints API, couverture minimum 70% +- Mises à jour doc : Obligatoire si changement API (OpenAPI), architecture (ADR), ou contrats (data_model.md) ## 7. Hotfix / Urgence -- Procédure : -- Responsables : (exemple: lead dev — a supprimer) +- Procédure : Branche `hotfix/description` depuis main, fix minimal, tests rapides, merge direct main, tag patch version +- Responsables : Développeur principal (projet solo), pas de process complexe pour mono-utilisateur --- @@ -54,9 +54,4 @@ Il sert de référence aux agents et aux contributeurs. - Réseau local : 10.0.0.0/22 - Passerelle : 10.0.0.1 ---- - -## Exemple (a supprimer) -- Branches : `main`, `develop`, `feat/*`, `fix/*`. -- Commits : Conventional Commits. -- CI : lint + tests + build. +--- \ No newline at end of file diff --git a/docs/adr/0001-choix-stack-technique.md b/docs/adr/0001-choix-stack-technique.md new file mode 100644 index 0000000..6963b5f --- /dev/null +++ b/docs/adr/0001-choix-stack-technique.md @@ -0,0 +1,192 @@ +# ADR-0001 — Choix de la stack technique globale + +- Statut : accepted +- Date : 2026-01-27 + +--- + +## Contexte + +HomeStock est une application web self-hosted de gestion d'inventaire domestique destinée à un usage mono-utilisateur. Les contraintes principales sont : + +- **Simplicité de déploiement** : L'application doit être facile à installer et maintenir sur un réseau local +- **Usage mono-utilisateur** : Pas besoin de scalabilité horizontale ni de gestion complexe de concurrence +- **Self-hosted** : Déploiement local, pas de dépendance cloud +- **Développement rapide** : Besoin d'un MVP fonctionnel rapidement avec des technologies modernes et productives +- **Maintenance à long terme** : Stack stable avec un bon écosystème et une documentation solide + +Le projet nécessite : +- Une API REST robuste pour le backend +- Une interface utilisateur moderne et réactive +- Une base de données adaptée au mono-utilisateur +- Un système de stockage de fichiers (photos, notices, factures) + +--- + +## Décision + +Nous avons choisi la stack technique suivante : + +### Backend +- **Langage** : Python 3.11+ +- **Framework API** : FastAPI +- **ORM** : SQLAlchemy 2.0+ avec Alembic pour migrations +- **Validation** : Pydantic (intégré à FastAPI) + +### Frontend +- **Langage** : TypeScript +- **Framework UI** : React 18+ +- **Bundler** : Vite 5+ +- **Gestion état** : TanStack Query (React Query) pour l'état serveur, Context API pour l'état local +- **Styling** : TailwindCSS avec palette Gruvbox dark + +### Base de données +- **SGBD** : SQLite avec extension FTS5 pour recherche full-text +- **Fichier** : `homestock.db` en local + +### Stockage fichiers +- **Système** : Système de fichiers local +- **Organisation** : Dossier `uploads/` avec sous-dossiers par type (photos/, notices/, factures/) +- **Référencement** : Chemins relatifs stockés en base de données + +### Déploiement +- **Conteneurisation** : Docker Compose +- **Réseau** : Réseau local 10.0.0.0/22 + +--- + +## Alternatives considérées + +### Backend +1. **Django + Django REST Framework** + - ✅ Framework très complet avec admin intégré + - ✅ ORM mature et puissant + - ❌ Plus lourd et verbeux que FastAPI + - ❌ Performance inférieure en async + - **Rejeté** : Trop de fonctionnalités inutiles pour notre cas d'usage mono-utilisateur + +2. **Go avec Gin/Echo** + - ✅ Très performant, binaire standalone + - ✅ Faible consommation mémoire + - ❌ Écosystème moins riche pour gestion fichiers/images + - ❌ Développement plus verbeux, moins rapide + - **Rejeté** : Performance non critique pour mono-utilisateur, productivité Python préférée + +3. **Node.js avec Express/Fastify** + - ✅ Écosystème npm très riche + - ✅ Même langage que le frontend (JavaScript/TypeScript) + - ❌ Gestion async plus complexe qu'avec FastAPI + - ❌ Préférence personnelle pour Python + - **Rejeté** : Moins d'expérience, Python préféré pour backend + +### Frontend +1. **Vue.js 3** + - ✅ Simple à apprendre, Composition API moderne + - ✅ Excellente documentation + - ❌ Écosystème légèrement moins riche que React + - **Rejeté** : React préféré pour son écosystème et l'expérience existante + +2. **Svelte/SvelteKit** + - ✅ Très léger, compilation au build + - ✅ Syntaxe simple et élégante + - ❌ Écosystème moins mature + - ❌ Moins de ressources et bibliothèques tierces + - **Rejeté** : Écosystème trop jeune pour un projet à long terme + +### Base de données +1. **PostgreSQL** + - ✅ Très robuste, fonctionnalités avancées + - ✅ Meilleure recherche full-text (GIN indexes) + - ❌ Serveur séparé à gérer, plus complexe + - ❌ Overhead pour mono-utilisateur avec faible volume + - **Rejeté** : Trop complexe pour un usage mono-utilisateur avec <10k items attendus + +2. **MySQL/MariaDB** + - ✅ Populaire, bien documenté + - ✅ Bonnes performances + - ❌ Recherche full-text moins performante + - ❌ Serveur séparé à gérer + - **Rejeté** : Même raison que PostgreSQL, SQLite suffit largement + +### Stockage fichiers +1. **MinIO (S3-compatible)** + - ✅ Scalable, versioning, métadonnées riches + - ✅ Compatible S3, facilite migration future + - ❌ Service supplémentaire à déployer et maintenir + - ❌ Overhead pour mono-utilisateur + - **Rejeté** : Trop complexe pour le besoin, système fichiers local suffit + +2. **Stockage en base (BLOB)** + - ✅ Pas de fichiers externes à gérer + - ✅ Transactions ACID sur fichiers + - ❌ Performance dégradée avec fichiers volumineux + - ❌ Backup et restauration plus complexes + - ❌ Taille de la base de données augmente rapidement + - **Rejeté** : Mauvaise pratique, problèmes de performance prévisibles + +--- + +## Conséquences + +### Positives +1. **Simplicité de déploiement** : Un seul `docker-compose up` lance l'ensemble de la stack +2. **Performance suffisante** : FastAPI async + SQLite suffisent largement pour mono-utilisateur +3. **Développement rapide** : FastAPI + React offrent une excellente productivité +4. **Pas de serveur BDD externe** : SQLite embarqué, un seul fichier à sauvegarder +5. **Auto-documentation API** : FastAPI génère automatiquement OpenAPI/Swagger +6. **Typage fort** : TypeScript (frontend) + Pydantic (backend) réduisent les erreurs +7. **Écosystèmes matures** : Python et React ont d'excellentes bibliothèques tierces +8. **Recherche performante** : FTS5 SQLite offre une recherche full-text native et rapide + +### Négatives +1. **Limitations SQLite** : Pas de scalabilité horizontale (non critique pour mono-utilisateur) +2. **Recherche limitée** : FTS5 moins puissant qu'Elasticsearch (acceptable pour <10k items) +3. **Stockage fichiers manuel** : Pas de métadonnées avancées ni versioning +4. **Backup manuel** : Nécessite scripts de backup pour fichiers + BDD +5. **Migration future** : Si passage multi-utilisateurs, migration vers PostgreSQL nécessaire + +### Neutres +1. **Deux langages** : Python (backend) + TypeScript (frontend) nécessitent deux environnements +2. **Complexité initiale React** : Courbe d'apprentissage pour développement React moderne + +--- + +## Impacts techniques + +### Organisation du code +- **Monorepo** : Backend et frontend dans le même dépôt +- **Structure backend** : `backend/app/` avec routers/, models/, schemas/, services/, repositories/ +- **Structure frontend** : `frontend/src/` avec components/, pages/, hooks/, api/ + +### Dépendances principales +- **Backend** : fastapi, uvicorn, sqlalchemy, alembic, pydantic, python-multipart, loguru +- **Frontend** : react, react-router-dom, @tanstack/react-query, tailwindcss, axios + +### Développement +- **Tests** : pytest (backend), vitest (frontend) +- **Lint/Format** : ruff + mypy (backend), eslint + prettier (frontend) +- **CI/CD** : Gitea Actions avec lint → test → build → deploy + +### Déploiement +- **Docker** : 3 services (backend, frontend, reverse proxy optionnel) +- **Volumes** : Persistance pour `homestock.db` et `uploads/` +- **Ports** : Backend :8000, Frontend :5173 (dev) ou :80/:443 (prod avec reverse proxy) + +### Évolutivité future +- **Migration PostgreSQL** : Possible via Alembic migrations si besoin +- **Stockage S3** : Abstraction stockage permet ajout MinIO sans refonte +- **Multi-utilisateurs** : Architecture modulaire facilite ajout authentification/RBAC + +--- + +## Notes + +Cette stack a été choisie en janvier 2026 pour un MVP mono-utilisateur. Les choix privilégient la simplicité et la rapidité de développement sur la scalabilité et les fonctionnalités avancées qui ne sont pas nécessaires pour le cas d'usage initial. + +La stack permet une évolution progressive vers multi-utilisateurs et stockage cloud si besoin, mais ces aspects sont volontairement exclus du MVP pour réduire la complexité initiale. + +Les alternatives (PostgreSQL, MinIO) restent envisageables pour des évolutions futures et sont documentées dans la section "Améliorations prévues" de [docs/ARCHITECTURE.md](../ARCHITECTURE.md). + +--- + +**Contributeurs** : Gilles (décideur) + Claude Code (architecte) diff --git a/docs/adr/0001-exemple.md b/docs/adr/0001-exemple.md deleted file mode 100644 index 6fa95cf..0000000 --- a/docs/adr/0001-exemple.md +++ /dev/null @@ -1,24 +0,0 @@ -# ADR-0001 — Exemple (a supprimer) - -- Statut : accepted -- Date : 2026-01-27 - -## Contexte -- Besoin d’un framework API simple et maintenable. - -## Décision -- Utiliser un monolithe modulaire. - -## Alternatives considérées -- Microservices. -- Serverless (exécution sans serveur dédié). - -## Conséquences -- Déploiement plus simple. -- Risque de croissance du monolithe. - -## Impacts techniques -- Découper par domaines. - -## Notes -- Revoir après 6 mois. diff --git a/docs/adr/0002-architecture-monolithe-modulaire.md b/docs/adr/0002-architecture-monolithe-modulaire.md new file mode 100644 index 0000000..e1ae94c --- /dev/null +++ b/docs/adr/0002-architecture-monolithe-modulaire.md @@ -0,0 +1,235 @@ +# ADR-0002 — Architecture monolithe modulaire + +- Statut : accepted +- Date : 2026-01-27 + +--- + +## Contexte + +HomeStock nécessite une architecture logicielle adaptée à un projet mono-utilisateur self-hosted avec les contraintes suivantes : + +- **Simplicité de déploiement** : L'application doit pouvoir être déployée facilement avec une seule commande +- **Maintenance réduite** : Pas d'équipe DevOps, maintenance manuelle par l'utilisateur +- **Développement solo** : Un seul développeur, besoin de cohérence et de simplicité +- **Évolutivité future** : Possibilité d'évoluer vers multi-utilisateurs sans refonte complète +- **Modules logiques distincts** : items, locations, categories, documents, search + +Le projet démarre avec un périmètre limité (MVP) mais doit pouvoir évoluer progressivement. La question architecturale centrale est : comment organiser le code pour maximiser la maintenabilité tout en gardant une complexité opérationnelle minimale ? + +--- + +## Décision + +Nous adoptons une **architecture monolithe modulaire** avec les caractéristiques suivantes : + +### Définition +- **Un seul dépôt Git** (monorepo) contenant backend et frontend +- **Un seul processus backend** (serveur FastAPI unique) +- **Modules logiques séparés** avec responsabilités clairement définies +- **Communication intra-application** via appels de fonctions directs +- **Base de données unique** partagée entre tous les modules + +### Organisation backend (`backend/app/`) +``` +backend/app/ +├── main.py # Point d'entrée FastAPI +├── core/ # Configuration, logging, database +│ ├── config.py +│ ├── database.py +│ └── logging.py +├── models/ # Modèles SQLAlchemy (un fichier par entité) +│ ├── item.py +│ ├── location.py +│ ├── category.py +│ └── document.py +├── schemas/ # Schémas Pydantic (validation API) +│ ├── item.py +│ ├── location.py +│ └── ... +├── routers/ # Endpoints API (un fichier par ressource) +│ ├── items.py +│ ├── locations.py +│ ├── categories.py +│ ├── documents.py +│ └── search.py +├── services/ # Logique métier (orchestration) +│ ├── item_service.py +│ ├── location_service.py +│ └── ... +├── repositories/ # Accès données (abstraction BDD) +│ ├── item_repository.py +│ ├── location_repository.py +│ └── ... +└── utils/ # Utilitaires transverses + ├── files.py + └── search.py +``` + +### Organisation frontend (`frontend/src/`) +``` +frontend/src/ +├── main.tsx # Point d'entrée React +├── App.tsx # Composant racine + routing +├── components/ # Composants réutilisables +│ ├── common/ # Boutons, inputs, modales... +│ ├── items/ # Composants spécifiques items +│ └── locations/ # Composants spécifiques locations +├── pages/ # Pages complètes (routes) +│ ├── Dashboard.tsx +│ ├── ItemList.tsx +│ ├── ItemDetail.tsx +│ └── ... +├── hooks/ # Custom hooks React +│ ├── useItems.ts +│ ├── useLocations.ts +│ └── ... +├── api/ # Client API (fetch/axios) +│ ├── items.ts +│ ├── locations.ts +│ └── ... +└── utils/ # Utilitaires helpers + └── formatting.ts +``` + +### Principes d'organisation +1. **Séparation en couches backend** : routers → services → repositories → models +2. **Dépendances unidirectionnelles** : Les couches supérieures dépendent des inférieures uniquement +3. **Modules auto-contenus** : Chaque module (items, locations, etc.) a ses propres fichiers dans chaque couche +4. **Pas de dépendances circulaires** entre modules +5. **Code partagé** dans `core/` et `utils/` + +--- + +## Alternatives considérées + +### 1. Microservices +**Description** : Séparer l'application en plusieurs services indépendants (service items, service locations, service search, etc.) + +**Avantages** : +- ✅ Scalabilité horizontale indépendante par service +- ✅ Isolation des pannes (un service down ≠ tout down) +- ✅ Possibilité d'utiliser des technologies différentes par service +- ✅ Déploiement indépendant de chaque service + +**Inconvénients** : +- ❌ Complexité opérationnelle énorme (orchestration, networking, monitoring) +- ❌ Nécessite API Gateway, service discovery, circuit breakers +- ❌ Communication réseau entre services (latence, sérialisations multiples) +- ❌ Transactions distribuées complexes (saga pattern) +- ❌ Debugging et traçabilité difficiles +- ❌ Overhead infrastructure (plusieurs conteneurs, load balancers, etc.) + +**Verdict** : ❌ **Rejeté** - Complexité totalement disproportionnée pour un projet mono-utilisateur avec ~1 req/seconde max. Les bénéfices de scalabilité ne s'appliquent pas au cas d'usage. + +### 2. Monolithe non structuré (big ball of mud) +**Description** : Tout le code dans quelques gros fichiers sans organisation claire + +**Avantages** : +- ✅ Démarrage très rapide +- ✅ Pas de contraintes architecturales + +**Inconvénients** : +- ❌ Maintenance cauchemardesque après quelques mois +- ❌ Code fortement couplé, difficile à tester +- ❌ Évolution impossible sans refonte complète +- ❌ Onboarding difficile pour contributeurs futurs + +**Verdict** : ❌ **Rejeté** - Dette technique garantie, inacceptable même pour un projet solo + +### 3. Architecture hexagonale (ports & adapters) +**Description** : Isolation stricte du domaine métier avec ports (interfaces) et adapters (implémentations) + +**Avantages** : +- ✅ Isolation forte du domaine métier +- ✅ Testabilité maximale (mocks faciles) +- ✅ Changement de base de données transparent + +**Inconvénients** : +- ❌ Boilerplate important (interfaces, adapters, DTOs multiples) +- ❌ Courbe d'apprentissage élevée +- ❌ Overhead pour un projet simple comme HomeStock + +**Verdict** : ⚠️ **Rejeté mais inspirant** - Trop complexe pour notre besoin, mais on garde l'idée de séparation en couches (repository pattern) + +### 4. Monolithe avec modules faiblement couplés (notre choix) +**Description** : Un seul déploiement avec modules logiques bien séparés et communication via interfaces claires + +**Avantages** : +- ✅ Simplicité opérationnelle (un seul processus, un seul conteneur) +- ✅ Transactions ACID simples (même base de données) +- ✅ Pas de latence réseau entre modules +- ✅ Refactoring et debugging faciles +- ✅ Évolution progressive possible (extraction en microservices si vraiment nécessaire) +- ✅ Maintenance réduite + +**Inconvénients** : +- ⚠️ Scalabilité horizontale limitée (non critique pour mono-utilisateur) +- ⚠️ Restart complet nécessaire pour tout changement (acceptable) + +**Verdict** : ✅ **Choisi** - Meilleur équilibre complexité/bénéfices pour notre contexte + +--- + +## Conséquences + +### Positives +1. **Déploiement simple** : `docker-compose up` lance tout le système +2. **Transactions ACID** : Toutes les opérations en base profitent des transactions SQLite +3. **Performance** : Pas de latence réseau entre modules, appels de fonctions directs +4. **Debugging facile** : Stack traces complètes, un seul processus à inspecter +5. **Refactoring sans risque** : IDE et linters détectent les erreurs lors de renommages +6. **Tests simples** : Tests d'intégration faciles (toute l'app dans le même process) +7. **Maintenance réduite** : Un seul service à monitorer, logs centralisés naturellement + +### Négatives +1. **Scalabilité limitée** : Impossible de scaler un module indépendamment (non pertinent pour mono-utilisateur) +2. **Couplage temporel** : Si un module plante, tout plante (acceptable avec bonne gestion erreurs) +3. **Restart complet** : Modification = restart de toute l'app (dev avec hot-reload, prod peu de déploiements) + +### Mitigations +- **Pour éviter le couplage fort** : Respecter strictement les couches (routers → services → repositories) +- **Pour faciliter évolution future** : Interfaces claires entre modules, pas de dépendances circulaires +- **Pour améliorer résilience** : Gestion d'erreurs robuste, retry logic, circuit breaker patterns si nécessaire + +--- + +## Impacts techniques + +### Développement +- **Pattern 3-layer** : Chaque feature touche 3 fichiers minimum (router, service, repository) +- **Imports Python** : Imports absolus depuis `app.` pour éviter imports relatifs fragiles +- **Tests** : Tests unitaires par couche + tests d'intégration API end-to-end + +### Déploiement +- **Docker** : Un seul conteneur backend (+ conteneur frontend séparé pour SPA) +- **Scaling** : Scale vertical uniquement (augmenter CPU/RAM du conteneur) +- **Rollback** : Simple (redéploiement image Docker précédente) + +### Évolution future +Si le projet nécessite vraiment des microservices (multi-utilisateurs massif, équipes séparées) : +1. **Extraction progressive** : Modules déjà bien séparés, extraction facilitée +2. **API interne → API externe** : Appels de fonctions deviennent appels HTTP +3. **Base partagée → bases séparées** : Migrations Alembic facilitent séparation BDD + +Cette architecture n'empêche pas l'évolution, elle la retarde jusqu'à ce qu'elle soit vraiment nécessaire (principe YAGNI = You Ain't Gonna Need It). + +--- + +## Notes + +Cette décision s'inscrit dans la philosophie "Start with a monolith" popularisée par Martin Fowler et confirmée par de nombreux retours d'expérience (Shopify, GitHub, Stack Overflow ont démarré en monolithe). + +Pour HomeStock, projet mono-utilisateur avec ~1 req/sec max en pic, un monolithe modulaire est largement suffisant et restera probablement optimal même à long terme. + +La complexité des microservices n'apporterait aucun bénéfice tangible et augmenterait drastiquement les coûts de développement et maintenance. + +**Principe appliqué** : "Choose boring technology" - privilégier les architectures éprouvées et simples sur les architectures à la mode mais complexes. + +--- + +**Contributeurs** : Gilles (décideur) + Claude Code (architecte) + +**Références** : +- Martin Fowler - "Monolith First" : https://martinfowler.com/bliki/MonolithFirst.html +- Sam Newman - "Building Microservices" (recommande de démarrer en monolithe) diff --git a/docs/adr/0003-recherche-full-text-sqlite-fts5.md b/docs/adr/0003-recherche-full-text-sqlite-fts5.md new file mode 100644 index 0000000..8b88159 --- /dev/null +++ b/docs/adr/0003-recherche-full-text-sqlite-fts5.md @@ -0,0 +1,259 @@ +# ADR-0003 — Recherche full-text avec SQLite FTS5 + +- Statut : accepted +- Date : 2026-01-27 + +--- + +## Contexte + +HomeStock nécessite une fonctionnalité de recherche rapide et efficace pour retrouver des objets dans l'inventaire. Les utilisateurs doivent pouvoir rechercher par : + +- Nom de l'objet (ex: "perceuse") +- Description (ex: "outil électrique sans fil") +- Catégorie (ex: "bricolage") +- Localisation (ex: "garage", "étagère A") + +**Contraintes identifiées** : +- **Volume de données** : ~1000-5000 items attendus initialement, maximum 10000 items à long terme +- **Performance** : Résultats de recherche en <200ms souhaités +- **Simplicité** : Pas de serveur externe supplémentaire à gérer +- **Pertinence** : Recherche "fuzzy" pas nécessaire, recherche exacte par mots-clés suffit +- **Langue** : Recherche en français uniquement + +Le choix de la solution de recherche impacte directement l'expérience utilisateur (fonctionnalité critique) et l'architecture technique (composant additionnel ou intégré). + +--- + +## Décision + +Nous utilisons **SQLite FTS5 (Full-Text Search 5)** pour la recherche full-text avec les caractéristiques suivantes : + +### Configuration +- **Table virtuelle FTS5** : `fts_items` contenant les champs indexés +- **Champs indexés** : `Item.name`, `Item.description`, `Category.name`, `Location.path` +- **Tokenizer** : `unicode61` (support Unicode, casse insensible) +- **Synchronisation** : Triggers SQLite pour maintenir FTS5 à jour lors des INSERT/UPDATE/DELETE + +### Exemple de structure +```sql +CREATE VIRTUAL TABLE fts_items USING fts5( + item_id UNINDEXED, + name, + description, + category_name, + location_path, + tokenize = 'unicode61' +); + +-- Triggers pour synchronisation automatique +CREATE TRIGGER items_after_insert AFTER INSERT ON items BEGIN + INSERT INTO fts_items(item_id, name, description, category_name, location_path) + VALUES ( + NEW.id, + NEW.name, + NEW.description, + (SELECT name FROM categories WHERE id = NEW.category_id), + (SELECT path FROM locations WHERE id = NEW.location_id) + ); +END; + +-- Triggers similaires pour UPDATE et DELETE +``` + +### Requêtes de recherche +```sql +-- Recherche simple +SELECT items.* FROM items +JOIN fts_items ON items.id = fts_items.item_id +WHERE fts_items MATCH 'perceuse'; + +-- Recherche avec boost (priorité sur le nom) +SELECT items.* FROM items +JOIN fts_items ON items.id = fts_items.item_id +WHERE fts_items MATCH '{name}: perceuse OR {description}: perceuse' +ORDER BY rank; +``` + +--- + +## Alternatives considérées + +### 1. Elasticsearch +**Description** : Moteur de recherche distribué dédié, standard de l'industrie + +**Avantages** : +- ✅ Recherche extrêmement performante et flexible +- ✅ Recherche fuzzy, phonétique, synonymes +- ✅ Agrégations et analytics avancées +- ✅ Scalable horizontalement + +**Inconvénients** : +- ❌ Serveur Java lourd (1-2GB RAM minimum) +- ❌ Complexité opérationnelle importante +- ❌ Nécessite synchronisation BDD → Elasticsearch +- ❌ Overhead massif pour 1000-10000 items +- ❌ Configuration complexe pour français + +**Verdict** : ❌ **Rejeté** - Tuer une mouche avec un bazooka. Elasticsearch est conçu pour des millions de documents et des cas d'usage complexes (facettes, agrégations, etc.) dont HomeStock n'a pas besoin. + +### 2. MeiliSearch +**Description** : Moteur de recherche moderne, léger, orienté UX + +**Avantages** : +- ✅ Très rapide et pertinent out-of-the-box +- ✅ API REST simple +- ✅ Typo-tolerance native +- ✅ Plus léger qu'Elasticsearch (~50MB RAM) +- ✅ Configuration minimale + +**Inconvénients** : +- ❌ Service externe supplémentaire à déployer +- ❌ Synchronisation BDD → MeiliSearch nécessaire +- ❌ Overhead pour petit volume de données +- ❌ Dépendance additionnelle à maintenir + +**Verdict** : ⚠️ **Rejeté mais intéressant** - Excellente solution technique mais ajoute de la complexité pour un bénéfice limité sur <10k items. À considérer si passage à >50k items. + +### 3. PostgreSQL Full-Text Search (pg_trgm + GIN) +**Description** : Recherche full-text native de PostgreSQL + +**Avantages** : +- ✅ Très performant avec index GIN +- ✅ Recherche trigram (similitude) +- ✅ Support langues naturelles (stemming français) +- ✅ Pas de service externe + +**Inconvénients** : +- ❌ Nécessite PostgreSQL (vs SQLite choisi dans ADR-0001) +- ❌ Serveur BDD à gérer +- ❌ Complexité setup (dictionnaires français, configuration) + +**Verdict** : ❌ **Rejeté** - Excellente solution technique mais nécessite PostgreSQL. Si migration vers PostgreSQL (cas multi-utilisateurs), reconsidérer. + +### 4. LIKE '%keyword%' en SQL +**Description** : Recherche basique avec opérateur LIKE + +**Avantages** : +- ✅ Extrêmement simple +- ✅ Aucune dépendance + +**Inconvénients** : +- ❌ Performance catastrophique (full table scan) +- ❌ Pas de pertinence/ranking +- ❌ Impossible de rechercher sur plusieurs champs efficacement +- ❌ Pas d'opérateurs (AND, OR, NOT) + +**Verdict** : ❌ **Rejeté** - Inacceptable même pour 1000 items, expérience utilisateur dégradée + +### 5. SQLite FTS5 (notre choix) +**Description** : Module de recherche full-text intégré à SQLite + +**Avantages** : +- ✅ Intégré à SQLite, pas de service externe +- ✅ Performance excellente pour <100k documents +- ✅ Support opérateurs (AND, OR, NOT, NEAR, phrases) +- ✅ Ranking par pertinence (BM25) +- ✅ Triggers pour synchronisation automatique +- ✅ Tokenizer Unicode (support français) +- ✅ Footprint mémoire minimal + +**Inconvénients** : +- ⚠️ Pas de recherche fuzzy native (typos) +- ⚠️ Pas de stemming français parfait +- ⚠️ Limitations si >100k documents (acceptable pour notre cas) + +**Verdict** : ✅ **Choisi** - Solution optimale pour notre contexte : performance suffisante, simplicité maximale, pas de dépendance externe + +--- + +## Conséquences + +### Positives +1. **Simplicité architecture** : Pas de service externe, tout dans SQLite +2. **Performance suffisante** : FTS5 recherche <50ms sur 10k items +3. **Synchronisation automatique** : Triggers maintiennent l'index à jour +4. **Zéro configuration** : FTS5 inclus dans SQLite depuis version 3.9.0 (2015) +5. **Backup simplifié** : Index FTS5 dans le même fichier .db que les données +6. **Opérateurs avancés** : Support AND, OR, NOT, recherche par phrase +7. **Ranking pertinent** : Algorithme BM25 pour trier par pertinence + +### Négatives +1. **Pas de typo-tolerance** : "perceuze" ne trouvera pas "perceuse" +2. **Stemming limité** : "perçant" et "percer" non reliés automatiquement +3. **Performance dégradée >100k items** : Limite théorique (très au-delà de notre besoin) +4. **Pas d'agrégations** : Impossible de faire des facettes de recherche complexes +5. **Recherche française limitée** : Pas de dictionnaire français sophistiqué + +### Mitigations +- **Typo-tolerance** : Implémenter suggestions de correction côté application si besoin (Levenshtein distance) +- **Stemming** : Ajouter mots-clés/tags aux items pour améliorer découvrabilité +- **Performance** : Si >100k items, migrer vers PostgreSQL pg_trgm ou MeiliSearch +- **Agrégations** : Implémenter filtres côté application (par catégorie, localisation, etc.) + +--- + +## Impacts techniques + +### Développement +- **Migration Alembic** : Créer table virtuelle FTS5 + triggers lors du setup initial +- **Service de recherche** : `search_service.py` encapsule la logique FTS5 +- **API endpoint** : `GET /api/v1/search?q=keyword` retourne items matchant + +### Performance attendue +- **<1000 items** : <10ms +- **1000-5000 items** : <50ms +- **5000-10000 items** : <150ms +- **>10000 items** : Dégrédation possible, monitoring nécessaire + +### Structure de code +```python +# services/search_service.py +class SearchService: + async def search_items(self, query: str, limit: int = 50): + """Recherche full-text avec FTS5""" + # Sanitize query (échapper caractères spéciaux) + safe_query = self._escape_fts5_query(query) + + # Query FTS5 avec ranking + sql = """ + SELECT items.*, + rank AS relevance + FROM items + JOIN fts_items ON items.id = fts_items.item_id + WHERE fts_items MATCH :query + ORDER BY rank + LIMIT :limit + """ + + return await self.db.execute(sql, {"query": safe_query, "limit": limit}) +``` + +### Évolution future +Si les limitations deviennent bloquantes : +1. **Migration PostgreSQL** : Activer pg_trgm pour recherche similitude +2. **Ajout MeiliSearch** : Service séparé pour recherche avancée (garder SQLite pour données) +3. **Hybrid search** : Combiner FTS5 (rapide) + recherche sémantique externe (typos, synonymes) + +--- + +## Notes + +SQLite FTS5 est utilisé par de nombreux projets à succès pour des cas d'usage similaires : +- **Firefox** : Recherche dans l'historique et favoris +- **Apple Mail** : Indexation locale des emails +- **VS Code** : Recherche dans l'historique de commandes + +Pour HomeStock avec <10k items attendus, FTS5 est largement suffisant et offre le meilleur ratio performance/simplicité. + +La décision de ne pas utiliser Elasticsearch ou MeiliSearch n'est pas dogmatique : si le projet évolue vers >50k items ou nécessite recherche fuzzy sophistiquée, migration possible sans refonte majeure (API de recherche reste identique, seule l'implémentation change). + +**Principe appliqué** : "Use the simplest tool that works" - Ne pas sur-architecturer pour des besoins hypothétiques futurs. + +--- + +**Contributeurs** : Gilles (décideur) + Claude Code (architecte) + +**Références** : +- SQLite FTS5 documentation : https://www.sqlite.org/fts5.html +- BM25 ranking algorithm : https://en.wikipedia.org/wiki/Okapi_BM25 diff --git a/docs/adr/0004-pas-authentification-reseau-local.md b/docs/adr/0004-pas-authentification-reseau-local.md new file mode 100644 index 0000000..00dcd0f --- /dev/null +++ b/docs/adr/0004-pas-authentification-reseau-local.md @@ -0,0 +1,226 @@ +# ADR-0004 — Pas d'authentification (réseau local uniquement) + +- Statut : accepted +- Date : 2026-01-27 + +--- + +## Contexte + +HomeStock est une application web self-hosted destinée à un usage mono-utilisateur sur un réseau local domestique. La question de la sécurité d'accès se pose : + +**Contraintes du projet** : +- **Déploiement** : Réseau local 10.0.0.0/22, pas d'exposition Internet prévue +- **Utilisateurs** : Mono-utilisateur (propriétaire du domicile) +- **Données** : Inventaire personnel, factures (non sensibles au sens RGPD) +- **Accès physique** : Réseau domestique, appareils de confiance uniquement + +**Questions à résoudre** : +1. Faut-il implémenter un système d'authentification (login/password) ? +2. Si oui, quel niveau de sécurité est nécessaire ? +3. Quels sont les risques réels d'un accès non authentifié sur réseau local ? + +L'authentification ajoute de la complexité (gestion sessions, hash passwords, UI login) et peut dégrader l'expérience utilisateur (saisie répétée de credentials) pour un bénéfice de sécurité potentiellement faible dans ce contexte. + +--- + +## Décision + +**Nous ne déployons PAS de système d'authentification pour le MVP.** L'application est accessible librement sur le réseau local sans login ni password. + +### Justification +1. **Périmètre réseau contrôlé** : Réseau domestique 10.0.0.0/22, tous les appareils sont de confiance +2. **Pas d'exposition Internet** : Application non accessible depuis l'extérieur, pas de port forwarding +3. **Mono-utilisateur** : Pas de besoin de gestion de sessions ou de rôles +4. **Simplicité UX** : Accès immédiat à l'application, pas de friction login +5. **Données non critiques** : Inventaire domestique et factures, pas de données bancaires ou médicales + +### Périmètre de sécurité +- ✅ **Réseau local uniquement** : Application bind sur IP privée, pas 0.0.0.0 +- ✅ **HTTPS optionnel** : TLS pour chiffrement en transit si certificat local disponible +- ✅ **Firewall** : Ports fermés en dehors du réseau local (configuration routeur) +- ❌ **Pas de login/password** : Accès direct à l'application +- ❌ **Pas de sessions** : Pas de gestion de tokens ou cookies d'authentification +- ❌ **Pas de RBAC** : Pas de rôles ou permissions (mono-utilisateur) + +### Configuration réseau recommandée +```yaml +# docker-compose.yml +services: + backend: + ports: + - "10.0.0.X:8000:8000" # Bind IP privée uniquement, pas 0.0.0.0:8000 + environment: + - ALLOWED_HOSTS=10.0.0.0/22 # Whitelist réseau local +``` + +--- + +## Alternatives considérées + +### 1. Authentification basique (login/password) +**Description** : Page de connexion avec username/password, session cookie + +**Avantages** : +- ✅ Protection contre accès non autorisé sur réseau local +- ✅ Traçabilité des accès (logs) +- ✅ Possibilité multi-utilisateurs future facilitée + +**Inconvénients** : +- ❌ Complexité additionnelle (hash passwords, sessions, CSRF, refresh tokens) +- ❌ UX dégradée (saisie credentials à chaque accès) +- ❌ Gestion mot de passe oublié nécessaire +- ❌ Sécurité illusoire si appareil compromis (cookies volés) + +**Verdict** : ❌ **Rejeté pour MVP** - Bénéfice limité sur réseau local de confiance, complexité injustifiée + +### 2. Authentification par IP (whitelist) +**Description** : Autoriser uniquement certaines IPs du réseau local + +**Avantages** : +- ✅ Simple à implémenter (middleware FastAPI) +- ✅ Pas d'UI login + +**Inconvénients** : +- ❌ Gestion fastidieuse des IPs autorisées +- ❌ DHCP complique le suivi (IPs dynamiques) +- ❌ Pas de granularité utilisateur + +**Verdict** : ❌ **Rejeté** - Complexité sans bénéfice majeur, réseau déjà isolé + +### 3. Authentification par certificat client (mTLS) +**Description** : Certificats X.509 pour authentifier les appareils + +**Avantages** : +- ✅ Sécurité forte +- ✅ Pas de password à mémoriser + +**Inconvénients** : +- ❌ Complexité extrême (PKI, distribution certificats) +- ❌ Configuration browser complexe +- ❌ Totalement surdimensionné pour le cas d'usage + +**Verdict** : ❌ **Rejeté** - Overkill absolu + +### 4. Pas d'authentification (notre choix) +**Description** : Accès libre sur réseau local + +**Avantages** : +- ✅ Simplicité maximale (pas de code auth) +- ✅ UX optimale (accès instantané) +- ✅ Maintenance réduite +- ✅ Adapté au contexte mono-utilisateur réseau local + +**Inconvénients** : +- ⚠️ Accès libre pour tout appareil sur réseau local +- ⚠️ Pas de traçabilité utilisateur +- ⚠️ Si exposition Internet accidentelle = vulnérabilité + +**Verdict** : ✅ **Choisi** - Solution adaptée au contexte avec mitigations appropriées + +--- + +## Conséquences + +### Positives +1. **Simplicité code** : Pas de code d'authentification, sessions, CSRF protection +2. **UX fluide** : Accès immédiat, pas de friction login +3. **Pas de gestion passwords** : Pas de hash, reset password, rotation +4. **Maintenance réduite** : Pas de vulnérabilités auth à monitorer (OWASP Top 10) +5. **Performance** : Pas de vérification session sur chaque requête + +### Négatives et risques +1. **Exposition accidentelle Internet** : Si port forwarding activé par erreur → accès public +2. **Appareil compromis sur réseau** : Malware sur laptop/smartphone pourrait accéder à l'app +3. **Visiteurs réseau** : Invités connectés au WiFi domestique peuvent accéder à l'app +4. **Pas de traçabilité** : Impossible de savoir qui a modifié/supprimé des données + +### Mitigations implémentées +1. **Bind IP privée uniquement** : Backend écoute sur 10.0.0.X:8000, pas 0.0.0.0 +2. **Firewall routeur** : Ports 8000/5173 fermés en WAN, ouverts LAN uniquement +3. **HTTPS optionnel** : Certificat auto-signé pour chiffrement en transit +4. **Logging applicatif** : Logs des actions (créer/modifier/supprimer items) avec IP source +5. **Backup régulier** : Scripts de backup pour récupération en cas d'actions malveillantes +6. **Documentation claire** : Avertissement dans README sur importance isolation réseau + +### Mitigations à envisager (post-MVP) +1. **Mode "invité" simple** : Code PIN basique si besoin de partager l'accès temporairement +2. **Alerte exposition** : Check au startup si l'app est accessible depuis Internet (API externe) +3. **Read-only mode** : Mode lecture seule pour appareils moins fiables + +--- + +## Impacts techniques + +### Code simplifié +Pas de code d'authentification signifie : +- Pas de modèle `User` en base +- Pas de hash passwords (bcrypt, argon2) +- Pas de gestion sessions/tokens +- Pas de middleware CSRF protection +- Pas d'endpoints `/login`, `/logout`, `/register` +- Pas d'UI login/signup + +### Configuration Docker +```yaml +# docker-compose.yml +services: + backend: + ports: + - "10.0.0.50:8000:8000" # IP privée fixe uniquement + environment: + - ALLOWED_ORIGINS=http://10.0.0.50:5173,http://localhost:5173 + - CORS_ALLOW_CREDENTIALS=false # Pas de cookies +``` + +### Logging renforcé +Même sans auth, logger les actions pour traçabilité : +```python +# Exemple log +logger.info( + "Item created", + extra={ + "item_id": item.id, + "item_name": item.name, + "source_ip": request.client.host, + "user_agent": request.headers.get("user-agent") + } +) +``` + +### Évolution future +Si exposition Internet devient nécessaire (accès depuis extérieur) : +1. **Ajouter authentification** : Login simple (username/password) + session cookie +2. **Reverse proxy avec auth** : Traefik BasicAuth ou OAuth2 Proxy +3. **VPN** : Accès via WireGuard/Tailscale (recommandé) +4. **Cloudflare Access** : Zero-trust avec auth externe + +--- + +## Notes + +Cette décision est **contextuelle** et adaptée à HomeStock : +- ✅ Réseau local domestique contrôlé +- ✅ Mono-utilisateur +- ✅ Données non critiques + +Elle serait **inappropriée** pour : +- ❌ Application exposée sur Internet +- ❌ Multi-utilisateurs +- ❌ Données sensibles (bancaires, médicales, secrets) +- ❌ Environnement professionnel + +**Analogie** : C'est comme ne pas mettre de serrure sur la porte de sa chambre dans sa propre maison (réseau local) vs mettre une serrure sur la porte d'entrée (exposition Internet). + +Si le contexte change (exposition Internet, ajout utilisateurs), cette décision devra être revisitée et l'authentification ajoutée. L'architecture modulaire (ADR-0002) facilite cet ajout futur sans refonte majeure. + +**Principe appliqué** : "Security proportionate to risk" - Ne pas sur-sécuriser quand le risque est faible et contrôlé. + +--- + +**Contributeurs** : Gilles (décideur) + Claude Code (architecte) + +**Avertissement important** : Cette configuration est sécurisée UNIQUEMENT si le réseau local est isolé d'Internet. Vérifier régulièrement que : +1. Aucun port forwarding n'est configuré sur le routeur +2. Aucun service de tunnel (ngrok, etc.) n'est actif +3. Le firewall du routeur est correctement configuré diff --git a/frontend/CONTEXT.md b/frontend/CONTEXT.md index ca0e269..3878543 100644 --- a/frontend/CONTEXT.md +++ b/frontend/CONTEXT.md @@ -12,34 +12,29 @@ Tout ce qui est indiqué ici est la référence pour les agents frontend. --- ## Objectif du frontend -- Parcours utilisateur principaux : (exemple: à personnaliser — a supprimer) -- Responsabilités principales : -- Hors périmètre : (exemple: à personnaliser — a supprimer) +- Parcours utilisateur principaux : Lister/rechercher items, consulter détails item avec documents, ajouter/éditer item avec upload fichiers, naviguer hiérarchie locations +- Responsabilités principales : Interface utilisateur responsive, formulaires validation, gestion état local/serveur, upload fichiers, recherche temps réel, affichage photos/documents +- Hors périmètre : Logique métier (déléguée au backend), stockage local complexe (cache géré par React Query), authentification (gérée par backend) ## Interfaces -- API consommées (API = Interface de Programmation) : -- Authentification/autorisation : -- Intégrations externes : (exemple: ERP existant — a supprimer) +- API consommées : Backend REST à `/api/v1/` (items, locations, categories, documents, search), client généré depuis OpenAPI ou fetch/axios manuel +- Authentification/autorisation : Optionnelle, si activée = gestion session cookie automatique par navigateur, redirection login si 401 +- Intégrations externes : Aucune intégration externe, consomme uniquement le backend HomeStock ## Architecture UI -- Framework : -- Structure des pages : -- Gestion d’état : -- Design system / UI kit (bibliothèque de composants) : +- Framework : React 18+ avec TypeScript, Vite comme bundler et dev server +- Structure des pages : `/` dashboard, `/items` liste items, `/items/:id` détail item, `/items/new` création, `/locations` gestion locations, routing avec React Router v6 +- Gestion d'état : TanStack Query (React Query) pour état serveur + cache, Context API pour état UI global (theme, navigation), useState/useReducer pour état local +- Design system / UI kit : TailwindCSS pour styling, composants custom inspirés de shadcn/ui (pas de dépendance lourde), palette Gruvbox dark ## Qualité & accessibilité -- Performance attendue : (exemple: à personnaliser — a supprimer) -- Accessibilité (a11y = accessibilité web) : -- Tests (unitaires/E2E = tests de bout en bout) : +- Performance attendue : FCP (First Contentful Paint) <1.5s, TTI (Time To Interactive) <3s sur réseau local, bundle JS <500KB gzippé +- Accessibilité : Accessibilité de base (ARIA labels, navigation clavier, contraste WCAG AA), pas de certification stricte WCAG AAA (usage personnel) +- Tests : Vitest pour tests unitaires (hooks, utils), Playwright optionnel pour tests E2E critiques (création item, upload fichier), pas de couverture exhaustive ## Conventions -- Organisation du code : -- Nommage : -- Gestion erreurs : +- Organisation du code : `frontend/src/` racine, sous-dossiers components/ (composants réutilisables), pages/ (vues complètes), hooks/ (custom hooks), api/ (clients API), utils/ (helpers) +- Nommage : PascalCase pour composants/fichiers React, camelCase pour variables/fonctions, préfixes use pour hooks custom, suffixes Page pour pages complètes +- Gestion erreurs : Error boundaries React pour erreurs render, gestion erreurs API via React Query (onError callbacks), toast notifications pour erreurs utilisateur, fallback UI gracieux ---- - -## Exemple (a supprimer) -- Framework : React + Vite. -- Pages : `dashboard`, `settings`, `billing`. -- État : Zustand + React Query. +--- \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index e69de29..b5f7372 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1 + +# Image de base Node.js 20 +FROM node:20-alpine + +# Répertoire de travail +WORKDIR /app + +# Copier les fichiers de dépendances +COPY package*.json ./ + +# Installer les dépendances +RUN npm install + +# Copier le code source +COPY . . + +# Exposer le port Vite (dev server) +EXPOSE 5173 + +# Commande par défaut (peut être overridée par docker-compose) +# Note: --host 0.0.0.0 permet l'accès depuis l'extérieur du conteneur +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e77e418 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + HomeStock - Inventaire Domestique + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8c9e5fb --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,58 @@ +{ + "name": "homestock-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "HomeStock - Frontend pour gestion d'inventaire domestique", + "author": "Gilles", + "license": "MIT", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "lint:fix": "eslint . --ext ts,tsx --fix", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", + "type-check": "tsc --noEmit", + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest --coverage" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.3", + "@tanstack/react-query": "^5.17.19", + "@tanstack/react-query-devtools": "^5.17.19", + "axios": "^1.6.5", + "clsx": "^2.1.0", + "date-fns": "^3.2.0", + "react-icons": "^5.4.0" + }, + "devDependencies": { + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@typescript-eslint/eslint-plugin": "^6.19.0", + "@typescript-eslint/parser": "^6.19.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.17", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.4.33", + "prettier": "^3.2.4", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3", + "vite": "^5.0.11", + "vitest": "^1.2.1", + "@vitest/ui": "^1.2.1", + "@testing-library/react": "^14.1.2", + "@testing-library/jest-dom": "^6.2.0", + "@testing-library/user-event": "^14.5.2" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=10.0.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..979ba8d --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,588 @@ +import { useState } from 'react' +import { BrowserRouter, Routes, Route, Link } from 'react-router-dom' +import { useCategories, useCreateCategory, useUpdateCategory, useDeleteCategory } from '@/hooks/useCategories' +import { useLocationTree, useCreateLocation, useUpdateLocation, useDeleteLocation } from '@/hooks/useLocations' +import { useItems, useCreateItem, useUpdateItem, useDeleteItem } from '@/hooks/useItems' +import { ItemList, ItemForm } from '@/components/items' +import { CategoryForm } from '@/components/categories' +import { LocationForm } from '@/components/locations' +import { + Loading, + ErrorMessage, + ConfirmDialog, + IconAdd, + IconEdit, + IconDelete, + IconHome, + IconInventory, + IconCategory, + IconLocation, + IconRoom, + IconFurniture, + IconDrawer, + IconBox, +} from '@/components/common' +import { + LOCATION_TYPE_LABELS, + LocationTree, + Category, + CategoryWithItemCount, + Location, + Item, + LocationType, +} from '@/api' + +// Mapping des icônes par type d'emplacement +const LOCATION_TYPE_ICONS: Record = { + room: , + furniture: , + drawer: , + box: , +} + +function App() { + return ( + +
+ {/* Header */} +
+
+
+ {/* Logo et titre */} + +

+ HomeStock +

+ + Inventaire Domestique + + + + {/* Navigation */} + +
+
+
+ + {/* Contenu principal */} +
+ + } /> + } /> + } /> + } /> + } /> + +
+ + {/* Footer */} +
+
+

+ HomeStock v{import.meta.env.VITE_APP_VERSION || '0.1.0'} - + Gestion d'inventaire domestique +

+
+
+
+
+ ) +} + +// === Page d'accueil avec statistiques === +function HomePage() { + const { data: categoriesData, isLoading: loadingCategories } = useCategories(1, 100) + const { data: itemsData, isLoading: loadingItems } = useItems(1, 1) + const { data: locationsData, isLoading: loadingLocations } = useLocationTree() + + const isLoading = loadingCategories || loadingItems || loadingLocations + + // Compter les emplacements + const countLocations = (tree: LocationTree[]): number => { + return tree.reduce((acc, loc) => acc + 1 + countLocations(loc.children), 0) + } + + const stats = { + items: itemsData?.total || 0, + categories: categoriesData?.total || 0, + locations: locationsData ? countLocations(locationsData) : 0, + } + + return ( +
+
+

+ Bienvenue sur HomeStock +

+

+ Gérez votre inventaire domestique facilement +

+
+ + {/* Statistiques */} + {isLoading ? ( + + ) : ( +
+ +
{stats.items}
+
Objets
+ + +
{stats.categories}
+
Catégories
+ + +
{stats.locations}
+
Emplacements
+ +
+ )} + + {/* Catégories */} + {categoriesData && categoriesData.items.length > 0 && ( +
+

Catégories

+
+ {categoriesData.items.map((cat) => ( + + + {cat.name} + ({cat.item_count}) + + ))} +
+
+ )} +
+ ) +} + +// === Page des objets === +function ItemsPage() { + const [showForm, setShowForm] = useState(false) + const [editingItem, setEditingItem] = useState(null) + const [deletingItem, setDeletingItem] = useState(null) + + const { data: categoriesData } = useCategories(1, 100) + const { data: locationsData } = useLocationTree() + + const createItem = useCreateItem() + const updateItem = useUpdateItem() + const deleteItem = useDeleteItem() + + const handleCreate = () => { + setEditingItem(null) + setShowForm(true) + } + + const handleEdit = (item: Item) => { + setEditingItem(item) + setShowForm(true) + } + + const handleSubmit = async (data: any) => { + if (editingItem) { + await updateItem.mutateAsync({ id: editingItem.id, data }) + } else { + await createItem.mutateAsync(data) + } + setShowForm(false) + setEditingItem(null) + } + + const handleDelete = async () => { + if (deletingItem) { + await deleteItem.mutateAsync(deletingItem.id) + setDeletingItem(null) + } + } + + return ( +
+
+

Mes Objets

+ +
+ + { + // TODO: ouvrir le détail de l'objet + console.log('Item clicked:', id) + }} + onItemEdit={handleEdit} + onItemDelete={setDeletingItem} + /> + + {/* Formulaire création/édition */} + { + setShowForm(false) + setEditingItem(null) + }} + onSubmit={handleSubmit} + item={editingItem} + categories={categoriesData?.items || []} + locations={locationsData || []} + isLoading={createItem.isPending || updateItem.isPending} + /> + + {/* Confirmation suppression */} + setDeletingItem(null)} + onConfirm={handleDelete} + title="Supprimer l'objet" + message={`Êtes-vous sûr de vouloir supprimer "${deletingItem?.name}" ? Cette action est irréversible.`} + confirmText="Supprimer" + isLoading={deleteItem.isPending} + /> +
+ ) +} + +// === Page des emplacements === +function LocationsPage() { + const [showForm, setShowForm] = useState(false) + const [editingLocation, setEditingLocation] = useState(null) + const [deletingLocation, setDeletingLocation] = useState(null) + const [defaultParentId, setDefaultParentId] = useState(null) + + const { data, isLoading, error, refetch } = useLocationTree() + const createLocation = useCreateLocation() + const updateLocation = useUpdateLocation() + const deleteLocation = useDeleteLocation() + + const handleCreate = (parentId: number | null = null) => { + setEditingLocation(null) + setDefaultParentId(parentId) + setShowForm(true) + } + + const handleEdit = (location: LocationTree) => { + // Convertir LocationTree en Location pour l'édition + setEditingLocation({ + id: location.id, + name: location.name, + type: location.type, + path: location.path, + parent_id: null, // On ne peut pas extraire ça de LocationTree + description: null, + created_at: '', + updated_at: '', + }) + setDefaultParentId(null) + setShowForm(true) + } + + const handleSubmit = async (formData: any) => { + if (editingLocation) { + await updateLocation.mutateAsync({ id: editingLocation.id, data: formData }) + } else { + await createLocation.mutateAsync(formData) + } + setShowForm(false) + setEditingLocation(null) + setDefaultParentId(null) + } + + const handleDelete = async () => { + if (deletingLocation) { + await deleteLocation.mutateAsync(deletingLocation.id) + setDeletingLocation(null) + } + } + + if (isLoading) return + if (error) return + + const renderTree = (locations: LocationTree[], level = 0) => { + return locations.map((loc) => ( +
+
+ + {LOCATION_TYPE_ICONS[loc.type]} + + {loc.name} + + ({LOCATION_TYPE_LABELS[loc.type]}) + + {loc.item_count > 0 && ( + + {loc.item_count} objet(s) + + )} + + {/* Actions */} +
+ + + +
+
+ {loc.children.length > 0 && renderTree(loc.children, level + 1)} +
+ )) + } + + return ( +
+
+

Mes Emplacements

+ +
+ + {data && data.length > 0 ? ( +
{renderTree(data)}
+ ) : ( +
+

+ Aucun emplacement créé. Commencez par créer une pièce. +

+
+ )} + + {/* Formulaire création/édition */} + { + setShowForm(false) + setEditingLocation(null) + setDefaultParentId(null) + }} + onSubmit={handleSubmit} + location={editingLocation} + locations={data || []} + defaultParentId={defaultParentId} + isLoading={createLocation.isPending || updateLocation.isPending} + /> + + {/* Confirmation suppression */} + setDeletingLocation(null)} + onConfirm={handleDelete} + title="Supprimer l'emplacement" + message={`Êtes-vous sûr de vouloir supprimer "${deletingLocation?.name}" ? ${ + deletingLocation && deletingLocation.children.length > 0 + ? 'Attention: cet emplacement contient des sous-emplacements qui seront aussi supprimés.' + : '' + }`} + confirmText="Supprimer" + isLoading={deleteLocation.isPending} + /> +
+ ) +} + +// === Page des catégories === +function CategoriesPage() { + const [showForm, setShowForm] = useState(false) + const [editingCategory, setEditingCategory] = useState(null) + const [deletingCategory, setDeletingCategory] = useState(null) + + const { data, isLoading, error, refetch } = useCategories(1, 100) + const createCategory = useCreateCategory() + const updateCategory = useUpdateCategory() + const deleteCategory = useDeleteCategory() + + const handleCreate = () => { + setEditingCategory(null) + setShowForm(true) + } + + const handleEdit = (category: CategoryWithItemCount) => { + setEditingCategory(category) + setShowForm(true) + } + + const handleSubmit = async (formData: any) => { + if (editingCategory) { + await updateCategory.mutateAsync({ id: editingCategory.id, data: formData }) + } else { + await createCategory.mutateAsync(formData) + } + setShowForm(false) + setEditingCategory(null) + } + + const handleDelete = async () => { + if (deletingCategory) { + await deleteCategory.mutateAsync(deletingCategory.id) + setDeletingCategory(null) + } + } + + if (isLoading) return + if (error) return + + return ( +
+
+

Mes Catégories

+ +
+ + {data && data.items.length > 0 ? ( +
+ {data.items.map((category) => ( +
+
+
+
+

{category.name}

+
+ + {/* Actions */} +
+ + +
+
+ {category.description && ( +

{category.description}

+ )} +
+ {category.item_count} objet(s) +
+
+ ))} +
+ ) : ( +
+

+ Aucune catégorie créée. Commencez par en créer une. +

+
+ )} + + {/* Formulaire création/édition */} + { + setShowForm(false) + setEditingCategory(null) + }} + onSubmit={handleSubmit} + category={editingCategory} + isLoading={createCategory.isPending || updateCategory.isPending} + /> + + {/* Confirmation suppression */} + setDeletingCategory(null)} + onConfirm={handleDelete} + title="Supprimer la catégorie" + message={ + deletingCategory?.item_count && deletingCategory.item_count > 0 + ? `Impossible de supprimer "${deletingCategory.name}" car elle contient ${deletingCategory.item_count} objet(s). Déplacez ou supprimez d'abord ces objets.` + : `Êtes-vous sûr de vouloir supprimer "${deletingCategory?.name}" ?` + } + confirmText="Supprimer" + isLoading={deleteCategory.isPending} + variant={deletingCategory?.item_count && deletingCategory.item_count > 0 ? 'warning' : 'danger'} + /> +
+ ) +} + +// === Page 404 === +function NotFoundPage() { + return ( +
+

404

+

+ Page non trouvée +

+ + Retour à l'accueil + +
+ ) +} + +export default App diff --git a/frontend/src/api/categories.ts b/frontend/src/api/categories.ts new file mode 100644 index 0000000..ed0642f --- /dev/null +++ b/frontend/src/api/categories.ts @@ -0,0 +1,50 @@ +/** + * API pour les catégories + */ + +import { apiClient, PaginatedResponse, SuccessResponse } from './client' +import { Category, CategoryCreate, CategoryUpdate, CategoryWithItemCount } from './types' + +export const categoriesApi = { + /** + * Liste toutes les catégories + */ + async getAll(page = 1, pageSize = 20): Promise> { + const response = await apiClient.get>('/categories', { + params: { page, page_size: pageSize }, + }) + return response.data + }, + + /** + * Récupère une catégorie par son ID + */ + async getById(id: number): Promise { + const response = await apiClient.get(`/categories/${id}`) + return response.data + }, + + /** + * Crée une nouvelle catégorie + */ + async create(data: CategoryCreate): Promise { + const response = await apiClient.post('/categories', data) + return response.data + }, + + /** + * Met à jour une catégorie + */ + async update(id: number, data: CategoryUpdate): Promise { + const response = await apiClient.put(`/categories/${id}`, data) + return response.data + }, + + /** + * Supprime une catégorie + */ + async delete(id: number): Promise { + const response = await apiClient.delete(`/categories/${id}`) + return response.data + }, +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..4281126 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,41 @@ +/** + * Client API Axios configuré pour HomeStock + */ + +import axios from 'axios' + +// URL de base de l'API +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1' + +// Instance Axios configurée +export const apiClient = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, + timeout: 10000, // 10 secondes +}) + +// Intercepteur pour logger les erreurs +apiClient.interceptors.response.use( + (response) => response, + (error) => { + console.error('API Error:', error.response?.data || error.message) + return Promise.reject(error) + } +) + +// Types de réponse paginée +export interface PaginatedResponse { + items: T[] + total: number + page: number + page_size: number + pages: number +} + +// Types communs +export interface SuccessResponse { + message: string + id?: number +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..c2258f2 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,9 @@ +/** + * Point d'entrée pour toutes les APIs + */ + +export * from './client' +export * from './types' +export { categoriesApi } from './categories' +export { locationsApi } from './locations' +export { itemsApi } from './items' diff --git a/frontend/src/api/items.ts b/frontend/src/api/items.ts new file mode 100644 index 0000000..009f44f --- /dev/null +++ b/frontend/src/api/items.ts @@ -0,0 +1,83 @@ +/** + * API pour les objets d'inventaire + */ + +import { apiClient, PaginatedResponse, SuccessResponse } from './client' +import { Item, ItemCreate, ItemFilter, ItemStatus, ItemUpdate, ItemWithRelations } from './types' + +export const itemsApi = { + /** + * Liste tous les objets avec filtres + */ + async getAll( + page = 1, + pageSize = 20, + filters?: ItemFilter + ): Promise> { + const response = await apiClient.get>('/items', { + params: { + page, + page_size: pageSize, + search: filters?.search, + category_id: filters?.category_id, + location_id: filters?.location_id, + status: filters?.status, + min_price: filters?.min_price, + max_price: filters?.max_price, + }, + }) + return response.data + }, + + /** + * Récupère un objet par son ID + */ + async getById(id: number): Promise { + const response = await apiClient.get(`/items/${id}`) + return response.data + }, + + /** + * Crée un nouvel objet + */ + async create(data: ItemCreate): Promise { + const response = await apiClient.post('/items', data) + return response.data + }, + + /** + * Met à jour un objet + */ + async update(id: number, data: ItemUpdate): Promise { + const response = await apiClient.put(`/items/${id}`, data) + return response.data + }, + + /** + * Supprime un objet + */ + async delete(id: number): Promise { + const response = await apiClient.delete(`/items/${id}`) + return response.data + }, + + /** + * Change le statut d'un objet + */ + async updateStatus(id: number, status: ItemStatus): Promise { + const response = await apiClient.patch(`/items/${id}/status`, null, { + params: { new_status: status }, + }) + return response.data + }, + + /** + * Déplace un objet vers un nouvel emplacement + */ + async move(id: number, locationId: number): Promise { + const response = await apiClient.patch(`/items/${id}/location`, null, { + params: { new_location_id: locationId }, + }) + return response.data + }, +} diff --git a/frontend/src/api/locations.ts b/frontend/src/api/locations.ts new file mode 100644 index 0000000..447321c --- /dev/null +++ b/frontend/src/api/locations.ts @@ -0,0 +1,91 @@ +/** + * API pour les emplacements + */ + +import { apiClient, PaginatedResponse, SuccessResponse } from './client' +import { + Location, + LocationCreate, + LocationTree, + LocationType, + LocationUpdate, + LocationWithItemCount, +} from './types' + +export const locationsApi = { + /** + * Liste tous les emplacements + */ + async getAll( + page = 1, + pageSize = 50, + parentId?: number, + type?: LocationType + ): Promise> { + const response = await apiClient.get>('/locations', { + params: { + page, + page_size: pageSize, + parent_id: parentId, + type, + }, + }) + return response.data + }, + + /** + * Récupère l'arborescence complète + */ + async getTree(): Promise { + const response = await apiClient.get('/locations/tree') + return response.data + }, + + /** + * Récupère les emplacements racine + */ + async getRoots(): Promise { + const response = await apiClient.get('/locations/roots') + return response.data + }, + + /** + * Récupère un emplacement par son ID + */ + async getById(id: number): Promise { + const response = await apiClient.get(`/locations/${id}`) + return response.data + }, + + /** + * Récupère les enfants d'un emplacement + */ + async getChildren(id: number): Promise { + const response = await apiClient.get(`/locations/${id}/children`) + return response.data + }, + + /** + * Crée un nouvel emplacement + */ + async create(data: LocationCreate): Promise { + const response = await apiClient.post('/locations', data) + return response.data + }, + + /** + * Met à jour un emplacement + */ + async update(id: number, data: LocationUpdate): Promise { + const response = await apiClient.put(`/locations/${id}`, data) + return response.data + }, + + /** + * Supprime un emplacement + */ + async delete(id: number): Promise { + const response = await apiClient.delete(`/locations/${id}`) + return response.data + }, +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..e7bafee --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,165 @@ +/** + * Types TypeScript pour les entités HomeStock + */ + +// === Catégories === +export interface Category { + id: number + name: string + description: string | null + color: string | null + icon: string | null + created_at: string + updated_at: string +} + +export interface CategoryWithItemCount extends Category { + item_count: number +} + +export interface CategoryCreate { + name: string + description?: string | null + color?: string | null + icon?: string | null +} + +export interface CategoryUpdate { + name?: string + description?: string | null + color?: string | null + icon?: string | null +} + +// === Emplacements === +export type LocationType = 'room' | 'furniture' | 'drawer' | 'box' + +export interface Location { + id: number + name: string + type: LocationType + parent_id: number | null + path: string + description: string | null + created_at: string + updated_at: string +} + +export interface LocationWithItemCount extends Location { + item_count: number +} + +export interface LocationTree { + id: number + name: string + type: LocationType + path: string + children: LocationTree[] + item_count: number +} + +export interface LocationCreate { + name: string + type: LocationType + parent_id?: number | null + description?: string | null +} + +export interface LocationUpdate { + name?: string + type?: LocationType + parent_id?: number | null + description?: string | null +} + +// === Objets === +export type ItemStatus = 'in_stock' | 'in_use' | 'broken' | 'sold' | 'lent' + +export interface Item { + id: number + name: string + description: string | null + quantity: number + status: ItemStatus + brand: string | null + model: string | null + serial_number: string | null + url: string | null + price: string | null + purchase_date: string | null + notes: string | null + category_id: number + location_id: number + created_at: string + updated_at: string +} + +export interface ItemWithRelations extends Item { + category: Category + location: Location +} + +export interface ItemCreate { + name: string + description?: string | null + quantity?: number + status?: ItemStatus + brand?: string | null + model?: string | null + serial_number?: string | null + url?: string | null + price?: number | null + purchase_date?: string | null + notes?: string | null + category_id: number + location_id: number +} + +export interface ItemUpdate { + name?: string + description?: string | null + quantity?: number + status?: ItemStatus + brand?: string | null + model?: string | null + serial_number?: string | null + url?: string | null + price?: number | null + purchase_date?: string | null + notes?: string | null + category_id?: number + location_id?: number +} + +export interface ItemFilter { + search?: string + category_id?: number + location_id?: number + status?: ItemStatus + min_price?: number + max_price?: number +} + +// === Labels pour l'affichage === +export const LOCATION_TYPE_LABELS: Record = { + room: 'Pièce', + furniture: 'Meuble', + drawer: 'Tiroir', + box: 'Boîte', +} + +export const ITEM_STATUS_LABELS: Record = { + in_stock: 'En stock', + in_use: 'En utilisation', + broken: 'Cassé', + sold: 'Vendu', + lent: 'Prêté', +} + +export const ITEM_STATUS_COLORS: Record = { + in_stock: 'bg-green-100 text-green-800', + in_use: 'bg-blue-100 text-blue-800', + broken: 'bg-red-100 text-red-800', + sold: 'bg-gray-100 text-gray-800', + lent: 'bg-yellow-100 text-yellow-800', +} diff --git a/frontend/src/components/categories/CategoryForm.tsx b/frontend/src/components/categories/CategoryForm.tsx new file mode 100644 index 0000000..6b8942a --- /dev/null +++ b/frontend/src/components/categories/CategoryForm.tsx @@ -0,0 +1,179 @@ +/** + * Formulaire de création/édition de catégorie + */ + +import { useState, useEffect } from 'react' +import { Category, CategoryCreate, CategoryUpdate } from '@/api/types' +import { Modal } from '@/components/common' + +interface CategoryFormProps { + isOpen: boolean + onClose: () => void + onSubmit: (data: CategoryCreate | CategoryUpdate) => void + category?: Category | null + isLoading?: boolean +} + +const PRESET_COLORS = [ + '#ef4444', // red + '#f97316', // orange + '#eab308', // yellow + '#22c55e', // green + '#14b8a6', // teal + '#3b82f6', // blue + '#8b5cf6', // violet + '#ec4899', // pink + '#6b7280', // gray +] + +export function CategoryForm({ + isOpen, + onClose, + onSubmit, + category, + isLoading = false, +}: CategoryFormProps) { + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [color, setColor] = useState('#3b82f6') + + const isEditing = !!category + + // Remplir le formulaire si édition + useEffect(() => { + if (category) { + setName(category.name) + setDescription(category.description || '') + setColor(category.color || '#3b82f6') + } else { + setName('') + setDescription('') + setColor('#3b82f6') + } + }, [category, isOpen]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + const data = { + name: name.trim(), + description: description.trim() || null, + color: color || null, + } + + onSubmit(data) + } + + const isValid = name.trim().length > 0 + + return ( + +
+ {/* Nom */} +
+ + setName(e.target.value)} + className="input" + placeholder="Ex: Électronique, Bricolage..." + required + autoFocus + /> +
+ + {/* Description */} +
+ +