From 26016e93bae8788833914953b337bbec3d3d76da Mon Sep 17 00:00:00 2001 From: Johan Date: Tue, 16 Dec 2025 16:54:12 +0100 Subject: [PATCH] tp done --- .gitignore | 1 + README.md | 484 ++++++++ poetry.lock | 1280 ++++++++++++++++++++++ pyproject.toml | 64 ++ resources/tp_fastapi.drawio | 167 +++ resources/tp_fastapi_uml.png | Bin 0 -> 54720 bytes src/app/__init__.py | 0 src/app/api/__init__.py | 0 src/app/api/deps.py | 10 + src/app/api/exception_handlers.py | 43 + src/app/api/routers/__init__.py | 0 src/app/api/routers/genres.py | 12 + src/app/api/routers/movies.py | 24 + src/app/api/routers/opinions.py | 21 + src/app/api/routers/participants.py | 28 + src/app/core/__init__.py | 0 src/app/core/config.py | 38 + src/app/core/exceptions.py | 25 + src/app/db/__init__.py | 0 src/app/db/seeding.py | 71 ++ src/app/db/session.py | 21 + src/app/main.py | 65 ++ src/app/models/__init__.py | 13 + src/app/models/base_class.py | 3 + src/app/models/genre.py | 13 + src/app/models/member.py | 33 + src/app/models/movie.py | 36 + src/app/models/movie_actors.py | 10 + src/app/models/opinion.py | 23 + src/app/models/participant.py | 21 + src/app/models/person.py | 21 + src/app/repositories/__init__.py | 0 src/app/repositories/genre.py | 14 + src/app/repositories/member.py | 13 + src/app/repositories/movie.py | 78 ++ src/app/repositories/opinion.py | 60 + src/app/repositories/participant.py | 60 + src/app/schemas/__init__.py | 0 src/app/schemas/genre.py | 13 + src/app/schemas/member.py | 9 + src/app/schemas/movie.py | 29 + src/app/schemas/opinion.py | 22 + src/app/schemas/participant.py | 23 + src/app/schemas/person.py | 15 + src/app/services/__init__.py | 0 src/app/services/genre.py | 9 + src/app/services/member.py | 11 + src/app/services/movie.py | 43 + src/app/services/opinion.py | 40 + src/app/services/participant.py | 23 + src/local_dev.db | Bin 0 -> 40960 bytes tests/__init__.py | 0 tests/api/test_movies_api.py | 69 ++ tests/conftest.py | 109 ++ tests/enonce/etape2_test_schemas.py | 60 + tests/enonce/etape3_test_repositories.py | 123 +++ tests/enonce/etape4_test_services.py | 98 ++ tests/enonce/etape5_test_api.py | 112 ++ tests/services/test_movie_service.py | 72 ++ 59 files changed, 3632 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 resources/tp_fastapi.drawio create mode 100644 resources/tp_fastapi_uml.png create mode 100644 src/app/__init__.py create mode 100644 src/app/api/__init__.py create mode 100644 src/app/api/deps.py create mode 100644 src/app/api/exception_handlers.py create mode 100644 src/app/api/routers/__init__.py create mode 100644 src/app/api/routers/genres.py create mode 100644 src/app/api/routers/movies.py create mode 100644 src/app/api/routers/opinions.py create mode 100644 src/app/api/routers/participants.py create mode 100644 src/app/core/__init__.py create mode 100644 src/app/core/config.py create mode 100644 src/app/core/exceptions.py create mode 100644 src/app/db/__init__.py create mode 100644 src/app/db/seeding.py create mode 100644 src/app/db/session.py create mode 100644 src/app/main.py create mode 100644 src/app/models/__init__.py create mode 100644 src/app/models/base_class.py create mode 100644 src/app/models/genre.py create mode 100644 src/app/models/member.py create mode 100644 src/app/models/movie.py create mode 100644 src/app/models/movie_actors.py create mode 100644 src/app/models/opinion.py create mode 100644 src/app/models/participant.py create mode 100644 src/app/models/person.py create mode 100644 src/app/repositories/__init__.py create mode 100644 src/app/repositories/genre.py create mode 100644 src/app/repositories/member.py create mode 100644 src/app/repositories/movie.py create mode 100644 src/app/repositories/opinion.py create mode 100644 src/app/repositories/participant.py create mode 100644 src/app/schemas/__init__.py create mode 100644 src/app/schemas/genre.py create mode 100644 src/app/schemas/member.py create mode 100644 src/app/schemas/movie.py create mode 100644 src/app/schemas/opinion.py create mode 100644 src/app/schemas/participant.py create mode 100644 src/app/schemas/person.py create mode 100644 src/app/services/__init__.py create mode 100644 src/app/services/genre.py create mode 100644 src/app/services/member.py create mode 100644 src/app/services/movie.py create mode 100644 src/app/services/opinion.py create mode 100644 src/app/services/participant.py create mode 100644 src/local_dev.db create mode 100644 tests/__init__.py create mode 100644 tests/api/test_movies_api.py create mode 100644 tests/conftest.py create mode 100644 tests/enonce/etape2_test_schemas.py create mode 100644 tests/enonce/etape3_test_repositories.py create mode 100644 tests/enonce/etape4_test_services.py create mode 100644 tests/enonce/etape5_test_api.py create mode 100644 tests/services/test_movie_service.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..757fee3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.idea \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c33b8ac --- /dev/null +++ b/README.md @@ -0,0 +1,484 @@ +# TP : Filmothèque (API REST) + +## Informations générales + +**Cours** : Python Avancé > REST API > Fast API \ +**Objectifs pédagogiques** : +- Créer une application backend basée sur FastAPI. +- Structurer une application en couches (API/Routeurs, BLL/Services, DAL/Repository). +- Définir des schémas de données avec Pydantic pour la validation et la sérialisation. +- Interagir avec une base de données en asynchrone grâce à SQLAlchemy. +- Mettre en place un système d'injection de dépendances (pour la session BDD). +- Gérer les erreurs de manière centralisée avec des gestionnaires d'exceptions personnalisés, en particulier pour DAL et BLL +- Tests : pytest +- Outils modernes et performants (poetry, PyCharm, FastAPI, SQLAlchemy 2.x) +- Bonnes pratiques de l'entreprise + +--- + +## Prérequis + +### Connaissances préalables + + * Connaissances de base en programmation. + * Application du modèle en couches (API, BLL, DAL). + +### Architecture en couches + +La pile applicative que nous allons construire suit une séparation claire des responsabilités : + +```mermaid +graph TD + A[API / Routeurs] -- "Appelle le" --> B[Services / BLL]; + B -- "Appelle le" --> C[Repository / DAL]; + C -- "Utilise l'" --> D[ORM / SQLAlchemy]; + D -- "Dialogue avec la" --> E[Base de donnees]; +``` + + * **API / Routeurs** : la porte d'entrée HTTP. + * **Services / BLL** : la logique métier (Business Logic Layer). + * **Repository / DAL** : l'abstraction de la persistance (Data Access Layer). + * **ORM / SQLAlchemy** : les modèles et la session qui parlent à la BDD. + +### Installation et configuration de l’environnement + +- Installation des dépendances : faire `poetry install` (fichier lock déja présent) +- À ce stade, si vos imports ne sont pas reconnus dans l'IDE PyCharm à l'ouverture d'un fichier (par exemple `app/main.py`), marquer le répertoire `src` comme `Sources Root` (Clic droit sur répertoire `src`, puis `Mark Directory as ... > Sources Root`) ou redémarrer l'IDE (File > Invalidate Caches... > Just restart à gauche). +- Par défaut, vous travaillerez avec une base de données locale `sqlite`. C'est fortement recommandé pour gagner du temps ! + +### Lancement du projet + +Une fois le projet cloné, se positionner dans le répertoire racine du projet (là où se trouve le fichier `pyproject.toml`). + +Faire clic droit puis `Settings` sur l'onglet du terminal local Pycharm, puis définir la variable d'environnement `BACKEND_CORS_ORIGINS`, en prévision de l'accès à ce micro service depuis un front Angular : +- `BACKEND_CORS_ORIGINS=["http://localhost:4200"]`) + +Ouvrir un (nouvel onglet) terminal local Pycharm, puis lancer le projet via : + +- `cd src` +- `uvicorn app.main:app --host 0.0.0.0 --port 8000` + +Les URLs suivantes devront alors être accessibles, selon vos avancées sur le projet : + +- http://127.0.0.1:8000/ -> retourne "welcome to this fantastic API" +- http://127.0.0.1:8000/api/v1/movies/ -> retourne une liste de films, éventuellement vide + +Notez qu'un pré-remplissage de la base est possible via le fichier `app/db/seeding.py`. +Si vous souhaitez réinitialiser la base, vous pouvez supprimer le fichier `local_dev.db` à la racine de votre projet, en prenant soin de décocher l'option `safe delete` (pas de refactor nécessaire à cet effet). + +--- + +## Énoncé + +### Modèle logique : schéma UML + +Vous trouverez ci-dessous pour information le schéma UML de la base de données. + +![Modèle logique UML](./resources/tp_fastapi_uml.png) + +### Structure du projet + +La structure du projet `tp_fastapi` est la suivante : + +``` +tp_fastapi/ + src/ + ├── app/ + │ ├── api/ + │ │ ├── __init__.py + │ │ ├── deps.py + │ │ ├── exception_handlers.py + │ │ └── routers/ # la porte d'entrée HTTP + │ │ ├── __init__.py + │ │ ├── genres.py + │ │ ├── movies.py + │ │ ├── opinions.py + │ │ └── participants.py + │ ├── core/ + │ │ ├── __init__.py + │ │ ├── config.py + │ │ └── exceptions.py + │ ├── db/ + │ │ ├── __init__.py + │ │ └── session.py + │ ├── models/ # les Business Objects (BO), qui sont également des entités pour l'ORM SQLAlchemy + │ │ ├── __init__.py + │ │ ├── base_class.py + │ │ ├── genre.py + │ │ ├── member.py + │ │ ├── movie.py + │ │ ├── movie_actors.py + │ │ ├── opinion.py + │ │ ├── participant.py + │ │ └── person.py + │ ├── repository/ # l'abstraction de la persistance (Data Access Layer) + │ │ ├── __init__.py + │ │ ├── genre.py + │ │ ├── movie.py + │ │ ├── opinion.py + │ │ └── participant.py + │ ├── schemas/ # objets manipulés lors des requêtes/réponses HTTP + │ │ ├── __init__.py + │ │ ├── genre.py + │ │ ├── movie.py + │ │ ├── opinion.py + │ │ ├── participant.py + │ │ └── person.py + │ ├── services/ # la logique métier (Business Logic Layer) + │ │ ├── __init__.py + │ │ ├── genre.py + │ │ ├── movie.py + │ │ ├── opinion.py + │ │ └── participant.py + │ ├── __init__.py + │ └── main.py +``` + +## Étape 1 : les fondations - modèles de données ORM + +L'Object-Relational Mapping (ORM) est la couche qui traduit nos objets Python en tables de base de données. +C'est une partie importante, mais complexe. +Pour que vous puissiez vous concentrer sur l'architecture de l'API, **le code de ce répertoire `app/models/` vous est intégralement fourni**. + +Votre mission est de **créer les fichiers et d'y copier le code ci-dessous**. Prenez le temps de lire et de comprendre les relations définies : +* `Person` est une classe de base utilisant l'**héritage** pour définir `Participant` (acteur/réalisateur) et `Member` (utilisateur). +* `Movie` a une relation **One-to-Many** avec `Opinion` (un film peut avoir plusieurs avis). +* `Movie` a une relation **Many-to-One** avec `Genre` et `Participant` (pour le réalisateur). +* `Movie` et `Participant` (pour les acteurs) ont une relation **Many-to-Many** via une table d'association. + +#### Fichier : `app/models/base_class.py` +```python +from sqlalchemy.orm import declarative_base +Base = declarative_base() +``` + +#### Fichier : `app/models/person.py` +```python +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column +from .base_class import Base + +class Person(Base): + __tablename__ = "persons" + id: Mapped[int] = mapped_column(primary_key=True) + last_name: Mapped[str] = mapped_column(String(255)) + first_name: Mapped[str] = mapped_column(String(255)) + # colonne discriminante pour la hiérarchie d'héritage + type: Mapped[str] = mapped_column(String(50)) + + __mapper_args__ = { + "polymorphic_identity": "person", + "polymorphic_on": "type", + } +``` + +#### Fichier : `app/models/participant.py` +```python +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from .person import Person + +class Participant(Person): + __tablename__ = "participants" + id: Mapped[int] = mapped_column(ForeignKey("persons.id"), primary_key=True) + __mapper_args__ = {"polymorphic_identity": "participant"} +``` + +#### Fichier : `app/models/genre.py` +```python +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column +from .base_class import Base + +class Genre(Base): + __tablename__ = "genres" + id: Mapped[int] = mapped_column(primary_key=True) + label: Mapped[str] = mapped_column(String(255)) +``` + +#### Fichier : `app/models/movie_actors.py` +```python +from sqlalchemy import Column, ForeignKey, Table +from .base_class import Base + +movie_actors_association_table = Table( + "movie_actors_association", + Base.metadata, + Column("movie_id", ForeignKey("movies.id"), primary_key=True), + Column("participant_id", ForeignKey("participants.id"), primary_key=True), +) +``` + +#### Fichier : `app/models/movie.py` +```python +from sqlalchemy import Integer, String, ForeignKey, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List +from .base_class import Base +from .movie_actors import movie_actors_association_table + +class Movie(Base): + __tablename__ = "movies" + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(250), nullable=False) + year: Mapped[int] = mapped_column(Integer) + duration: Mapped[int] = mapped_column(Integer) + synopsis: Mapped[str] = mapped_column(Text) + director_id: Mapped[int] = mapped_column(ForeignKey("participants.id")) + genre_id: Mapped[int] = mapped_column(ForeignKey("genres.id")) + + director: Mapped["Participant"] = relationship(foreign_keys=[director_id]) + genre: Mapped["Genre"] = relationship() + actors: Mapped[List["Participant"]] = relationship(secondary=movie_actors_association_table) + opinions: Mapped[List["Opinion"]] = relationship(back_populates="movie", cascade="all, delete-orphan") +``` + +#### Fichier : `app/models/opinion.py` + +```python +from sqlalchemy import ( + Integer, + ForeignKey, + Text +) +from sqlalchemy.orm import ( + Mapped, + mapped_column, + relationship +) +from .base_class import Base + +class Opinion(Base): + __tablename__ = "opinions" + id: Mapped[int] = mapped_column(primary_key=True) + note: Mapped[int] = mapped_column(Integer) + comment: Mapped[str] = mapped_column(Text) + + member_id: Mapped[int] = mapped_column(ForeignKey("members.id")) + movie_id: Mapped[int] = mapped_column(ForeignKey("movies.id")) + + member: Mapped["Member"] = relationship(back_populates="opinions") + movie: Mapped["Movie"] = relationship(back_populates="opinions") +``` + +#### Fichier : `app/models/member.py` + +```python +from sqlalchemy import ( + String, + Boolean, + ForeignKey, +) +from sqlalchemy.orm import ( + Mapped, + mapped_column, + relationship +) +from typing import List +from .person import Person + +class Member(Person): + __tablename__ = "members" + + # La clé primaire est aussi une clé étrangère vers la table parente + id: Mapped[int] = mapped_column(ForeignKey("persons.id"), primary_key=True) + + # Champs spécifiques à Member + login: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + password: Mapped[str] = mapped_column(String(255), nullable=False) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False) + + # La relation vers Opinion + opinions: Mapped[List["Opinion"]] = relationship(back_populates="member") + + __mapper_args__ = { + "polymorphic_identity": "member", + } + + def __repr__(self) -> str: + return f"Member(id={self.id}, login='{self.login}')" +``` + +#### Fichier : `app/models/__init__.py` + +Pensez également à **importer toutes les classes dans `app/models/__init__.py`** pour faciliter les imports ailleurs dans le projet : + +```python +# app/models/__init__.py + +# Eviter les erreurs liés à l'importation circulaire +# Exemple : sqlalchemy.exc.InvalidRequestError: When initializing mapper Mapper[Movie(movies)], expression 'Genre' failed to locate a name ('Genre'). If this is a class name, consider adding this relationship() to the class after both dependent classes have been defined + +from .base_class import Base +from .genre import Genre +from .member import Member +from .movie import Movie +from .opinion import Opinion +from .participant import Participant +from .person import Person + +# L'import des associations (ex : personnefilm) n'est en général pas nécessaire ici car elles sont gérées dans les modèles eux-mêmes +``` + +**Astuce :** lorsque vos fichiers sont prêts, et que vous aurez lancé l'application une première fois, vous pourrez désactiver la drop/création automatique des tables dans `main.py` (mettre en commentaire `await conn.run_sync(Base.metadata.drop_all)`). + +--- + +## Étape 2 : les schémas de données avec Pydantic + +Les schémas Pydantic définissent la "forme" des données que notre API attend en entrée et renvoie en sortie. Ils assurent une validation robuste et automatique. + +Votre mission : **Créez les fichiers dans `app/schemas/`** et écrivez les classes Pydantic. Une bonne pratique est de créer : +* Une classe `Base` (champs communs). +* Une classe `Create` (champs requis pour la création). +* Une classe `Read` (pour la lecture, avec les `id` et les relations). + +**Exemple pour `genre.py` :** +```python +from pydantic import BaseModel, ConfigDict + +class GenreBase(BaseModel): + label: str + +class GenreCreate(GenreBase): + pass + +class GenreRead(GenreBase): + id: int + model_config = ConfigDict(from_attributes=True) +``` +Inspirez-vous de cet exemple et des modèles ORM pour créer les schémas pour `Person`, `Participant`, `Opinion` et `Movie`. + +**Astuce pour `Movie` :** Le schéma de création `MovieCreate` attendra des IDs (`director_id`, `actors_ids`), tandis que le schéma de lecture `MovieRead` renverra les objets imbriqués (`director: Person`, `actors: List[Person]`). + +Pour valider votre travail, vous pouvez lancer le test suivant, à la racine du projet : `poetry run pytest tests/enonce/etape2_test_schemas.py`. + +--- + +## Étape 3 : la couche d'accès aux données (DAL / Repository) + +Dans la couche DAL (Data Access Layer), le Repository est responsable de toutes les requêtes à la base de données. C'est ici que vous écrirez vos requêtes SQLAlchemy. L'objectif est de cacher la complexité de l'ORM au reste de l'application. + +Votre mission : **Complétez les fonctions dans les fichiers du répertoire `app/repositories/`**. +Toutes les fonctions recevront une session `db: AsyncSession` en paramètre. + +**Exemple pour `repositories\movie.py` :** +* **`get_movie(db, movie_id)`** : Doit récupérer un film par son ID. Pensez à utiliser `selectinload` pour charger ses relations (genre, réalisateur, acteurs) de manière efficace et éviter le problème N+1. +* **`get_movies(db, skip, limit)`** : Récupère une liste de films paginée. +* **`create_movie(db, movie)`** : Crée une nouvelle instance `Movie`, l'ajoute à la session et la commit. + +**Important** : entourez vos requêtes d'un bloc `try...except SQLAlchemyError` et levez une `DALException` personnalisée en cas d'erreur. + +Pour valider votre travail, vous pouvez lancer le test suivant, à la racine du projet : `poetry run pytest tests/enonce/etape3_test_repositories.py`. + +--- + +## Étape 4 : la logique métier (BLL / Services) + +Le Service est le cœur de votre logique. Il orchestre les appels au Repository et applique les règles métier. C'est lui qui décide si une action est valide ou non. + +Votre mission : **Implémentez la logique dans les fichiers du répertoire `app/services/`**. + +**Exemple pour `services\movie.py` :** +* Dans `create_movie`, avant d'appeler le repository, **ajoutez des vérifications** : + 1. Le titre du film ne doit pas être vide. + 2. L'année de sortie doit être réaliste (ex: entre 1888 et aujourd'hui + 5 ans). + 3. Si une de ces règles n'est pas respectée, levez une `ValidationBLLException`. +* Dans `get_movie_by_id`, si le repository ne retourne aucun film, levez une `NotFoundBLLException`. + +**Principe clé** : Un service ne doit jamais manipuler directement des exceptions HTTP (`HTTPException`). Il utilise des exceptions métier personnalisées (`BLLException`, `NotFoundBLLException`, etc.) pour rester indépendant du framework web. + +Pour valider votre travail, vous pouvez lancer le test suivant, à la racine du projet : `poetry run pytest tests/enonce/etape4_test_services.py`. + +--- + +## Étape 5 : la couche API (Routers) + +C'est ici que tout se connecte ! Les routeurs FastAPI définissent les endpoints de votre API, reçoivent les requêtes HTTP, appellent les services appropriés et retournent les réponses. + +Votre mission : **Créez les endpoints dans les fichiers du répertoire `app/api/routers/`**. + +* Utilisez les décorateurs de FastAPI (`@router.get`, `@router.post`, etc.). +* Utilisez `response_model` pour spécifier le schéma Pydantic de la réponse. +* Utilisez `status_code` pour définir le code de statut HTTP approprié (ex: `201 CREATED`). +* Injectez la session de base de données en utilisant `db: AsyncSession = Depends(get_db)`. +* Appelez la fonction de service correspondante et retournez son résultat. + +Pour valider votre travail, vous pouvez lancer le test suivant, à la racine du projet : `poetry run pytest tests/enonce/etape5_test_api.py`. + +--- + +## Étape 6 : l'assemblage final et la gestion des erreurs + +La dernière étape consiste à configurer l'application FastAPI principale, à connecter les routeurs et à mettre en place la gestion centralisée des exceptions. + +Votre mission : **Complétez le fichier `app/main.py` et observez `app/api/exception_handlers.py`**. + +1. Dans `main.py` : + * Créez l'instance `FastAPI`, si ce n'est pas déja fait. + * Utilisez `app.include_router()` pour ajouter chaque routeur que vous avez créé. + * Utilisez `@app.add_exception_handler()` pour lier vos exceptions métier personnalisées (`NotFoundBLLException`, `ValidationBLLException`, `DALException`, `BLLException`) à des fonctions de gestion. + +2. Dans `app/api/exception_handlers.py`, observez les points suivants : + * Les fonctions `async def` (ex: `not_found_bll_exception_handler`) qui prennent `Request` et votre exception en paramètres. À quoi servent-elles ? + * Ces fonctions doivent retourner une `JSONResponse` avec le code de statut HTTP adéquat (`404`, `400`, `500`) et un message d'erreur clair dans le contenu. Pourquoi c'est une bonne pratique avec REST de retourner un code status HTTP approprié ? Est-ce que le code en question sépare les erreurs techniques des erreurs fonctionnelles ? + +Le schéma ci-dessous illustre comment les exceptions personnalisées sont levées par les couches internes (DAL, BLL) et interceptées par les gestionnaires centraux (handlers) définis dans `main.py` pour produire une réponse HTTP propre. + +```mermaid +graph TD + subgraph "Flux de requete" + A[Requete HTTP] --> B[Endpoint API] + B -- Appelle --> C(Service BLL) + C -- Appelle --> D(Repository DAL) + end + + subgraph "Flux d'exception" + D -- Leve --> E{DALException} + C -- Leve --> F{NotFoundBLLException} + C -- Leve --> G{ValidationBLLException} + + H[main.py: @app.add_exception_handler] -- Enregistre --> I(handler_dal) + H -- Enregistre --> J(handler_not_found) + H -- Enregistre --> K(handler_validation) + + E -.-> I + F -.-> J + G -.-> K + + I -- Genere --> L[JSONResponse 500] + J -- Genere --> M[JSONResponse 404] + K -- Genere --> N[JSONResponse 400] + end +``` + +Une fois terminé, lancez votre application avec `uvicorn app.main:app --reload` (depuis le dossier `src/`) et explorez la documentation interactive sur `http://127.0.0.1:8000/docs`. + +----- + +## Aller plus loin (bonus) + +* Vérifier que l'intégralité des tests passent. Se positionner à la racine du projet (avant le src), puis lancer les tests unitaires via : + - `poetry run pytest` + - `poetry run pytest -vv` (avec davantage d'éléments de debug) +* Implémentez-les endpoints `UPDATE` et `DELETE` pour les participants et les films. +* Ajoutez des filtres à la route `GET /movies/` (par année, par genre, etc.). +* Lancer les tests unitaires suivants : `poetry run pytest tests/api/test_movies_api.py` et `poetry run pytest tests/services/test_movie_service.py` +* Écrivez d'autres tests unitaires et d'intégration avec `pytest`. +* Mettre en œuvre une véritable base de données MySQL : + +Si vous souhaitez établir une connexion avec une véritable base de données telle que MySQL, sachez que le package `asyncmy` sera requis (`poetry add asyncmy@^0.2.10`), ainsi qu'au préalable sous Windows, l'installation des redistributables Visual Studio 2022 (via l'outil vsBuildTools dont le binaire et la documentation sont transmis dans votre `00_Install_Pack.zip`). + +``` +La base doit être créée au préalable (exemple en root) : + +CREATE DATABASE filmotheque CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +Un utilisateur filmotheque (mdp : filmotheque) doit être créé avec les droits sur cette base. +``` + +Faire clic droit puis Settings sur l'onglet du terminal local Pycharm, puis définir la variable d'environnement DATABASE_URL, contenant l'URL de connexion MySQL adéquate : + +`DATABASE_URL=mysql+asyncmy://filmotheque:filmotheque@127.0.0.1:3306/filmotheque` + +Fermez l'onglet du terminal, puis en ouvrir un autre, afin que les modifications soient effectives \ No newline at end of file diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..6a9e118 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1280 @@ +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. + +[[package]] +name = "aiosqlite" +version = "0.21.0" +description = "asyncio bridge to the standard sqlite3 module" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0"}, + {file = "aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3"}, +] + +[package.dependencies] +typing_extensions = ">=4.0" + +[package.extras] +dev = ["attribution (==1.7.1)", "black (==24.3.0)", "build (>=1.2)", "coverage[toml] (==7.6.10)", "flake8 (==7.0.0)", "flake8-bugbear (==24.12.12)", "flit (==3.10.1)", "mypy (==1.14.1)", "ufmt (==2.5.1)", "usort (==1.0.8.post1)"] +docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.1)"] + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "3.7.1" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, + {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] +test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4) ; python_version < \"3.8\"", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17) ; python_version < \"3.12\" and platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] +trio = ["trio (<0.22)"] + +[[package]] +name = "certifi" +version = "2025.8.3" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, + {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, +] + +[[package]] +name = "click" +version = "8.2.1" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, + {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "sys_platform == \"win32\""} + +[[package]] +name = "coverage" +version = "7.10.0" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "coverage-7.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cbd823f7ea5286c26406ad9e54268544d82f3d1cadb6d4f3b85e9877f0cab1ef"}, + {file = "coverage-7.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ab3f7a5dbaab937df0b9e9e8ec6eab235ba9a6f29d71fd3b24335affaed886cc"}, + {file = "coverage-7.10.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8c63aaf850523d8cbe3f5f1a5c78f689b223797bef902635f2493ab43498f36c"}, + {file = "coverage-7.10.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c3133ce3fa84023f7c6921c4dca711be0b658784c5a51a797168229eae26172"}, + {file = "coverage-7.10.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3747d1d0af85b17d3a156cd30e4bbacf893815e846dc6c07050e9769da2b138e"}, + {file = "coverage-7.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:241923b350437f6a7cb343d9df72998305ef940c3c40009f06e05029a047677c"}, + {file = "coverage-7.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:13e82e499309307104d58ac66f9eed237f7aaceab4325416645be34064d9a2be"}, + {file = "coverage-7.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bf73cdde4f6c9cd4457b00bf1696236796ac3a241f859a55e0f84a4c58326a7f"}, + {file = "coverage-7.10.0-cp310-cp310-win32.whl", hash = "sha256:2396e13275b37870a3345f58bce8b15a7e0a985771d13a4b16ce9129954e07d6"}, + {file = "coverage-7.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:9d45c7c71fb3d2da92ab893602e3f28f2d1560cec765a27e1824a6e0f7e92cfd"}, + {file = "coverage-7.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4abc01843581a6f9dd72d4d15761861190973a2305416639435ef509288f7a04"}, + {file = "coverage-7.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2093297773111d7d748fe4a99b68747e57994531fb5c57bbe439af17c11c169"}, + {file = "coverage-7.10.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58240e27815bf105bd975c2fd42e700839f93d5aad034ef976411193ca32dbfd"}, + {file = "coverage-7.10.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d019eac999b40ad48521ea057958b07a9f549c0c6d257a20e5c7c4ba91af8d1c"}, + {file = "coverage-7.10.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0a1f5454bc80faf4ceab10d1d48f025f92046c9c0f3bec2e1a9dda55137f8"}, + {file = "coverage-7.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a93dd7759c416dd1cc754123b926d065055cb9a33b6699e64a1e5bdfae1ff459"}, + {file = "coverage-7.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7b3d737266048368a6ffd68f1ecd662c54de56535c82eb8f98a55ac216a72cbd"}, + {file = "coverage-7.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:93227c2707cb0effd9163cd0d8f0d9ab628982f7a3e915d6d64c7107867b9a07"}, + {file = "coverage-7.10.0-cp311-cp311-win32.whl", hash = "sha256:69270af3014ab3058ad6108c6d0e218166f568b5a7a070dc3d62c0a63aca1c4d"}, + {file = "coverage-7.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:43c16bbb661a7b4dafac0ab69e44d6dbcc6a64c4d93aefd89edc6f8911b6ab4a"}, + {file = "coverage-7.10.0-cp311-cp311-win_arm64.whl", hash = "sha256:14e7c23fcb74ed808efb4eb48fcd25a759f0e20f685f83266d1df174860e4733"}, + {file = "coverage-7.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a2adcfdaf3b4d69b0c64ad024fe9dd6996782b52790fb6033d90f36f39e287df"}, + {file = "coverage-7.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d7b27c2c0840e8eeff3f1963782bd9d3bc767488d2e67a31de18d724327f9f6"}, + {file = "coverage-7.10.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0ed50429786e935517570b08576a661fd79032e6060985ab492b9d39ba8e66ee"}, + {file = "coverage-7.10.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7171c139ab6571d70460ecf788b1dcaf376bfc75a42e1946b8c031d062bbbad4"}, + {file = "coverage-7.10.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a726aac7e6e406e403cdee4c443a13aed3ea3d67d856414c5beacac2e70c04e"}, + {file = "coverage-7.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2886257481a14e953e96861a00c0fe7151117a523f0470a51e392f00640bba03"}, + {file = "coverage-7.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:536578b79521e59c385a2e0a14a5dc2a8edd58761a966d79368413e339fc9535"}, + {file = "coverage-7.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77fae95558f7804a9ceefabf3c38ad41af1da92b39781b87197c6440dcaaa967"}, + {file = "coverage-7.10.0-cp312-cp312-win32.whl", hash = "sha256:97803e14736493eb029558e1502fe507bd6a08af277a5c8eeccf05c3e970cb84"}, + {file = "coverage-7.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:4c73ab554e54ffd38d114d6bc4a7115fb0c840cf6d8622211bee3da26e4bd25d"}, + {file = "coverage-7.10.0-cp312-cp312-win_arm64.whl", hash = "sha256:3ae95d5a9aedab853641026b71b2ddd01983a0a7e9bf870a20ef3c8f5d904699"}, + {file = "coverage-7.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d883fee92b9245c0120fa25b5d36de71ccd4cfc29735906a448271e935d8d86d"}, + {file = "coverage-7.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c87e59e88268d30e33d3665ede4fbb77b513981a2df0059e7c106ca3de537586"}, + {file = "coverage-7.10.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f669d969f669a11d6ceee0b733e491d9a50573eb92a71ffab13b15f3aa2665d4"}, + {file = "coverage-7.10.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9582bd6c6771300a847d328c1c4204e751dbc339a9e249eecdc48cada41f72e6"}, + {file = "coverage-7.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91f97e9637dc7977842776fdb7ad142075d6fa40bc1b91cb73685265e0d31d32"}, + {file = "coverage-7.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae4fa92b6601a62367c6c9967ad32ad4e28a89af54b6bb37d740946b0e0534dd"}, + {file = "coverage-7.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3a5cc8b97473e7b3623dd17a42d2194a2b49de8afecf8d7d03c8987237a9552c"}, + {file = "coverage-7.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc1cbb7f623250e047c32bd7aa1bb62ebc62608d5004d74df095e1059141ac88"}, + {file = "coverage-7.10.0-cp313-cp313-win32.whl", hash = "sha256:1380cc5666d778e77f1587cd88cc317158111f44d54c0dd3975f0936993284e0"}, + {file = "coverage-7.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:bf03cf176af098ee578b754a03add4690b82bdfe070adfb5d192d0b1cd15cf82"}, + {file = "coverage-7.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:8041c78cd145088116db2329b2fb6e89dc338116c962fbe654b7e9f5d72ab957"}, + {file = "coverage-7.10.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:37cc2c06052771f48651160c080a86431884db9cd62ba622cab71049b90a95b3"}, + {file = "coverage-7.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:91f37270b16178b05fa107d85713d29bf21606e37b652d38646eef5f2dfbd458"}, + {file = "coverage-7.10.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f9b0b0168864d09bcb9a3837548f75121645c4cfd0efce0eb994c221955c5b10"}, + {file = "coverage-7.10.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df0be435d3b616e7d3ee3f9ebbc0d784a213986fe5dff9c6f1042ee7cfd30157"}, + {file = "coverage-7.10.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e9aba1c4434b837b1d567a533feba5ce205e8e91179c97974b28a14c23d3a0"}, + {file = "coverage-7.10.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a0b0c481e74dfad631bdc2c883e57d8b058e5c90ba8ef087600995daf7bbec18"}, + {file = "coverage-7.10.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8aec1b7c8922808a433c13cd44ace6fceac0609f4587773f6c8217a06102674b"}, + {file = "coverage-7.10.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:04ec59ceb3a594af0927f2e0d810e1221212abd9a2e6b5b917769ff48760b460"}, + {file = "coverage-7.10.0-cp313-cp313t-win32.whl", hash = "sha256:b6871e62d29646eb9b3f5f92def59e7575daea1587db21f99e2b19561187abda"}, + {file = "coverage-7.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff99cff2be44f78920b76803f782e91ffb46ccc7fa89eccccc0da3ca94285b64"}, + {file = "coverage-7.10.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3246b63501348fe47299d12c47a27cfc221cfbffa1c2d857bcc8151323a4ae4f"}, + {file = "coverage-7.10.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:1f628d91f941a375b4503cb486148dbeeffb48e17bc080e0f0adfee729361574"}, + {file = "coverage-7.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a0e101d5af952d233557e445f42ebace20b06b4ceb615581595ced5386caa78"}, + {file = "coverage-7.10.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec4c1abbcc53f9f650acb14ea71725d88246a9e14ed42f8dd1b4e1b694e9d842"}, + {file = "coverage-7.10.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9c95f3a7f041b4cc68a8e3fecfa6366170c13ac773841049f1cd19c8650094e0"}, + {file = "coverage-7.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a2cd597b69c16d24e310611f2ed6fcfb8f09429316038c03a57e7b4f5345244"}, + {file = "coverage-7.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5e18591906a40c2b3609196c9879136aa4a47c5405052ca6b065ab10cb0b71d0"}, + {file = "coverage-7.10.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:485c55744252ed3f300cc1a0f5f365e684a0f2651a7aed301f7a67125906b80e"}, + {file = "coverage-7.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4dabea1516e5b0e9577282b149c8015e4dceeb606da66fb8d9d75932d5799bf5"}, + {file = "coverage-7.10.0-cp314-cp314-win32.whl", hash = "sha256:ac455f0537af22333fdc23b824cff81110dff2d47300bb2490f947b7c9a16017"}, + {file = "coverage-7.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:b3c94b532f52f95f36fbfde3e178510a4d04eea640b484b2fe8f1491338dc653"}, + {file = "coverage-7.10.0-cp314-cp314-win_arm64.whl", hash = "sha256:2f807f2c3a9da99c80dfa73f09ef5fc3bd21e70c73ba1c538f23396a3a772252"}, + {file = "coverage-7.10.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0a889ef25215990f65073c32cadf37483363a6a22914186dedc15a6b1a597d50"}, + {file = "coverage-7.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39c638ecf3123805bacbf71aff8091e93af490c676fca10ab4e442375076e483"}, + {file = "coverage-7.10.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2f2c0df0cbcf7dffa14f88a99c530cdef3f4fcfe935fa4f95d28be2e7ebc570"}, + {file = "coverage-7.10.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:048d19a5d641a2296745ab59f34a27b89a08c48d6d432685f22aac0ec1ea447f"}, + {file = "coverage-7.10.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1209b65d302d7a762004be37ab9396cbd8c99525ed572bdf455477e3a9449e06"}, + {file = "coverage-7.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e44aa79a36a7a0aec6ea109905a4a7c28552d90f34e5941b36217ae9556657d5"}, + {file = "coverage-7.10.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96124be864b89395770c9a14652afcddbcdafb99466f53a9281c51d1466fb741"}, + {file = "coverage-7.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aad222e841f94b42bd1d6be71737fade66943853f0807cf87887c88f70883a2a"}, + {file = "coverage-7.10.0-cp314-cp314t-win32.whl", hash = "sha256:0eed5354d28caa5c8ad60e07e938f253e4b2810ea7dd56784339b6ce98b6f104"}, + {file = "coverage-7.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3da35f9980058acb960b2644527cc3911f1e00f94d309d704b309fa984029109"}, + {file = "coverage-7.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cb9e138dfa8a4b5c52c92a537651e2ca4f2ca48d8cb1bc01a2cbe7a5773c2426"}, + {file = "coverage-7.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cf283ec9c6878826291b17442eb5c32d3d252dc77d25e082b460b2d2ea67ba3c"}, + {file = "coverage-7.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8a83488c9fc6fff487f2ab551f9b64c70672357b8949f0951b0cd778b3ed8165"}, + {file = "coverage-7.10.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b86df3a7494d12338c11e59f210a0498d6109bbc3a4037f44de517ebb30a9c6b"}, + {file = "coverage-7.10.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6de9b460809e5e4787b742e786a36ae2346a53982e2be317cdcb7a33c56412fb"}, + {file = "coverage-7.10.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de5ef8a5954d63fa26a6aaa4600e48f885ce70fe495e8fce2c43aa9241fc9434"}, + {file = "coverage-7.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f178fe5e96f1e057527d5d0b20ab76b8616e0410169c33716cc226118eaf2c4f"}, + {file = "coverage-7.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4a38c42f0182a012fa9ec25bc6057e51114c1ba125be304f3f776d6d283cb303"}, + {file = "coverage-7.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bf09beb5c1785cb36aad042455c0afab561399b74bb8cdaf6e82b7d77322df99"}, + {file = "coverage-7.10.0-cp39-cp39-win32.whl", hash = "sha256:cb8dfbb5d3016cb8d1940444c0c69b40cdc6c8bde724b07716ee5ea47b5273c6"}, + {file = "coverage-7.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:58ff22653cd93d563110d1ff2aef958f5f21be9e917762f8124d0e36f80f172a"}, + {file = "coverage-7.10.0-py3-none-any.whl", hash = "sha256:310a786330bb0463775c21d68e26e79973839b66d29e065c5787122b8dd4489f"}, + {file = "coverage-7.10.0.tar.gz", hash = "sha256:2768885aef484b5dcde56262cbdfba559b770bfc46994fe9485dc3614c7a5867"}, +] + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "dnspython" +version = "2.7.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"}, + {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"}, +] + +[package.extras] +dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] +dnssec = ["cryptography (>=43)"] +doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] +doq = ["aioquic (>=1.0.0)"] +idna = ["idna (>=3.7)"] +trio = ["trio (>=0.23)"] +wmi = ["wmi (>=1.5.1)"] + +[[package]] +name = "email-validator" +version = "2.2.0" +description = "A robust email address syntax and deliverability validation library." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"}, + {file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +idna = ">=2.0.0" + +[[package]] +name = "fastapi" +version = "0.116.1" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565"}, + {file = "fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.40.0,<0.48.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "greenlet" +version = "3.2.3" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version == \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")" +files = [ + {file = "greenlet-3.2.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:1afd685acd5597349ee6d7a88a8bec83ce13c106ac78c196ee9dde7c04fe87be"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:761917cac215c61e9dc7324b2606107b3b292a8349bdebb31503ab4de3f559ac"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a433dbc54e4a37e4fff90ef34f25a8c00aed99b06856f0119dcf09fbafa16392"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:72e77ed69312bab0434d7292316d5afd6896192ac4327d44f3d613ecb85b037c"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:68671180e3849b963649254a882cd544a3c75bfcd2c527346ad8bb53494444db"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49c8cfb18fb419b3d08e011228ef8a25882397f3a859b9fe1436946140b6756b"}, + {file = "greenlet-3.2.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:efc6dc8a792243c31f2f5674b670b3a95d46fa1c6a912b8e310d6f542e7b0712"}, + {file = "greenlet-3.2.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:731e154aba8e757aedd0781d4b240f1225b075b4409f1bb83b05ff410582cf00"}, + {file = "greenlet-3.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:96c20252c2f792defe9a115d3287e14811036d51e78b3aaddbee23b69b216302"}, + {file = "greenlet-3.2.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:784ae58bba89fa1fa5733d170d42486580cab9decda3484779f4759345b29822"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0921ac4ea42a5315d3446120ad48f90c3a6b9bb93dd9b3cf4e4d84a66e42de83"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d2971d93bb99e05f8c2c0c2f4aa9484a18d98c4c3bd3c62b65b7e6ae33dfcfaf"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c667c0bf9d406b77a15c924ef3285e1e05250948001220368e039b6aa5b5034b"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:592c12fb1165be74592f5de0d70f82bc5ba552ac44800d632214b76089945147"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29e184536ba333003540790ba29829ac14bb645514fbd7e32af331e8202a62a5"}, + {file = "greenlet-3.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:93c0bb79844a367782ec4f429d07589417052e621aa39a5ac1fb99c5aa308edc"}, + {file = "greenlet-3.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:751261fc5ad7b6705f5f76726567375bb2104a059454e0226e1eef6c756748ba"}, + {file = "greenlet-3.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:83a8761c75312361aa2b5b903b79da97f13f556164a7dd2d5448655425bd4c34"}, + {file = "greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb"}, + {file = "greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c"}, + {file = "greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163"}, + {file = "greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849"}, + {file = "greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b"}, + {file = "greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0"}, + {file = "greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36"}, + {file = "greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3"}, + {file = "greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141"}, + {file = "greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a"}, + {file = "greenlet-3.2.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:42efc522c0bd75ffa11a71e09cd8a399d83fafe36db250a87cf1dacfaa15dc64"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d760f9bdfe79bff803bad32b4d8ffb2c1d2ce906313fc10a83976ffb73d64ca7"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8324319cbd7b35b97990090808fdc99c27fe5338f87db50514959f8059999805"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:8c37ef5b3787567d322331d5250e44e42b58c8c713859b8a04c6065f27efbf72"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ce539fb52fb774d0802175d37fcff5c723e2c7d249c65916257f0a940cee8904"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:003c930e0e074db83559edc8705f3a2d066d4aa8c2f198aff1e454946efd0f26"}, + {file = "greenlet-3.2.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7e70ea4384b81ef9e84192e8a77fb87573138aa5d4feee541d8014e452b434da"}, + {file = "greenlet-3.2.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:22eb5ba839c4b2156f18f76768233fe44b23a31decd9cc0d4cc8141c211fd1b4"}, + {file = "greenlet-3.2.3-cp39-cp39-win32.whl", hash = "sha256:4532f0d25df67f896d137431b13f4cdce89f7e3d4a96387a41290910df4d3a57"}, + {file = "greenlet-3.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:aaa7aae1e7f75eaa3ae400ad98f8644bb81e1dc6ba47ce8a93d3f17274e08322"}, + {file = "greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil"] + +[[package]] +name = "gunicorn" +version = "23.0.0" +description = "WSGI HTTP Server for UNIX" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, + {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, +] + +[package.dependencies] +packaging = "*" + +[package.extras] +eventlet = ["eventlet (>=0.24.1,!=0.36.0)"] +gevent = ["gevent (>=1.4.0)"] +setproctitle = ["setproctitle"] +testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] +tornado = ["tornado (>=0.2)"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httptools" +version = "0.6.4" +description = "A collection of framework independent HTTP protocol utils." +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0"}, + {file = "httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da"}, + {file = "httptools-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deee0e3343f98ee8047e9f4c5bc7cedbf69f5734454a94c38ee829fb2d5fa3c1"}, + {file = "httptools-0.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca80b7485c76f768a3bc83ea58373f8db7b015551117375e4918e2aa77ea9b50"}, + {file = "httptools-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90d96a385fa941283ebd231464045187a31ad932ebfa541be8edf5b3c2328959"}, + {file = "httptools-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:59e724f8b332319e2875efd360e61ac07f33b492889284a3e05e6d13746876f4"}, + {file = "httptools-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:c26f313951f6e26147833fc923f78f95604bbec812a43e5ee37f26dc9e5a686c"}, + {file = "httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069"}, + {file = "httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a"}, + {file = "httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975"}, + {file = "httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636"}, + {file = "httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721"}, + {file = "httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988"}, + {file = "httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17"}, + {file = "httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2"}, + {file = "httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44"}, + {file = "httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1"}, + {file = "httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2"}, + {file = "httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81"}, + {file = "httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f"}, + {file = "httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970"}, + {file = "httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660"}, + {file = "httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083"}, + {file = "httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3"}, + {file = "httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071"}, + {file = "httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5"}, + {file = "httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0"}, + {file = "httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8"}, + {file = "httptools-0.6.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:d3f0d369e7ffbe59c4b6116a44d6a8eb4783aae027f2c0b366cf0aa964185dba"}, + {file = "httptools-0.6.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:94978a49b8f4569ad607cd4946b759d90b285e39c0d4640c6b36ca7a3ddf2efc"}, + {file = "httptools-0.6.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40dc6a8e399e15ea525305a2ddba998b0af5caa2566bcd79dcbe8948181eeaff"}, + {file = "httptools-0.6.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab9ba8dcf59de5181f6be44a77458e45a578fc99c31510b8c65b7d5acc3cf490"}, + {file = "httptools-0.6.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:fc411e1c0a7dcd2f902c7c48cf079947a7e65b5485dea9decb82b9105ca71a43"}, + {file = "httptools-0.6.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:d54efd20338ac52ba31e7da78e4a72570cf729fac82bc31ff9199bedf1dc7440"}, + {file = "httptools-0.6.4-cp38-cp38-win_amd64.whl", hash = "sha256:df959752a0c2748a65ab5387d08287abf6779ae9165916fe053e68ae1fbdc47f"}, + {file = "httptools-0.6.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:85797e37e8eeaa5439d33e556662cc370e474445d5fab24dcadc65a8ffb04003"}, + {file = "httptools-0.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:db353d22843cf1028f43c3651581e4bb49374d85692a85f95f7b9a130e1b2cab"}, + {file = "httptools-0.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1ffd262a73d7c28424252381a5b854c19d9de5f56f075445d33919a637e3547"}, + {file = "httptools-0.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:703c346571fa50d2e9856a37d7cd9435a25e7fd15e236c397bf224afaa355fe9"}, + {file = "httptools-0.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aafe0f1918ed07b67c1e838f950b1c1fabc683030477e60b335649b8020e1076"}, + {file = "httptools-0.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0e563e54979e97b6d13f1bbc05a96109923e76b901f786a5eae36e99c01237bd"}, + {file = "httptools-0.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:b799de31416ecc589ad79dd85a0b2657a8fe39327944998dea368c1d4c9e55e6"}, + {file = "httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c"}, +] + +[package.extras] +test = ["Cython (>=0.29.24)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["main", "dev"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pydantic" +version = "2.11.7" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, + {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""} +pydantic-core = "2.33.2" +typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, + {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pydantic-settings" +version = "2.10.1" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796"}, + {file = "pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" + +[package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "8.4.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, + {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "1.1.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf"}, + {file = "pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea"}, +] + +[package.dependencies] +pytest = ">=8.2,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-cov" +version = "6.2.1" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5"}, + {file = "pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2"}, +] + +[package.dependencies] +coverage = {version = ">=7.5", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=6.2.5" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pytest-mock" +version = "3.14.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0"}, + {file = "pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, + {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.42" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "SQLAlchemy-2.0.42-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7ee065898359fdee83961aed5cf1fb4cfa913ba71b58b41e036001d90bebbf7a"}, + {file = "SQLAlchemy-2.0.42-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56bc76d86216443daa2e27e6b04a9b96423f0b69b5d0c40c7f4b9a4cdf7d8d90"}, + {file = "SQLAlchemy-2.0.42-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89143290fb94c50a8dec73b06109ccd245efd8011d24fc0ddafe89dc55b36651"}, + {file = "SQLAlchemy-2.0.42-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:4efbdc9754c7145a954911bfeef815fb0843e8edab0e9cecfa3417a5cbd316af"}, + {file = "SQLAlchemy-2.0.42-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:88f8a8007a658dfd82c16a20bd9673ae6b33576c003b5166d42697d49e496e61"}, + {file = "SQLAlchemy-2.0.42-cp37-cp37m-win32.whl", hash = "sha256:c5dd245e6502990ccf612d51f220a7b04cbea3f00f6030691ffe27def76ca79b"}, + {file = "SQLAlchemy-2.0.42-cp37-cp37m-win_amd64.whl", hash = "sha256:5651eb19cacbeb2fe7431e4019312ed00a0b3fbd2d701423e0e2ceaadb5bcd9f"}, + {file = "sqlalchemy-2.0.42-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:172b244753e034d91a826f80a9a70f4cbac690641207f2217f8404c261473efe"}, + {file = "sqlalchemy-2.0.42-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:be28f88abd74af8519a4542185ee80ca914933ca65cdfa99504d82af0e4210df"}, + {file = "sqlalchemy-2.0.42-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98b344859d282fde388047f1710860bb23f4098f705491e06b8ab52a48aafea9"}, + {file = "sqlalchemy-2.0.42-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97978d223b11f1d161390a96f28c49a13ce48fdd2fed7683167c39bdb1b8aa09"}, + {file = "sqlalchemy-2.0.42-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e35b9b000c59fcac2867ab3a79fc368a6caca8706741beab3b799d47005b3407"}, + {file = "sqlalchemy-2.0.42-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bc7347ad7a7b1c78b94177f2d57263113bb950e62c59b96ed839b131ea4234e1"}, + {file = "sqlalchemy-2.0.42-cp310-cp310-win32.whl", hash = "sha256:739e58879b20a179156b63aa21f05ccacfd3e28e08e9c2b630ff55cd7177c4f1"}, + {file = "sqlalchemy-2.0.42-cp310-cp310-win_amd64.whl", hash = "sha256:1aef304ada61b81f1955196f584b9e72b798ed525a7c0b46e09e98397393297b"}, + {file = "sqlalchemy-2.0.42-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c34100c0b7ea31fbc113c124bcf93a53094f8951c7bf39c45f39d327bad6d1e7"}, + {file = "sqlalchemy-2.0.42-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ad59dbe4d1252448c19d171dfba14c74e7950b46dc49d015722a4a06bfdab2b0"}, + {file = "sqlalchemy-2.0.42-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9187498c2149919753a7fd51766ea9c8eecdec7da47c1b955fa8090bc642eaa"}, + {file = "sqlalchemy-2.0.42-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f092cf83ebcafba23a247f5e03f99f5436e3ef026d01c8213b5eca48ad6efa9"}, + {file = "sqlalchemy-2.0.42-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc6afee7e66fdba4f5a68610b487c1f754fccdc53894a9567785932dbb6a265e"}, + {file = "sqlalchemy-2.0.42-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:260ca1d2e5910f1f1ad3fe0113f8fab28657cee2542cb48c2f342ed90046e8ec"}, + {file = "sqlalchemy-2.0.42-cp311-cp311-win32.whl", hash = "sha256:2eb539fd83185a85e5fcd6b19214e1c734ab0351d81505b0f987705ba0a1e231"}, + {file = "sqlalchemy-2.0.42-cp311-cp311-win_amd64.whl", hash = "sha256:9193fa484bf00dcc1804aecbb4f528f1123c04bad6a08d7710c909750fa76aeb"}, + {file = "sqlalchemy-2.0.42-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:09637a0872689d3eb71c41e249c6f422e3e18bbd05b4cd258193cfc7a9a50da2"}, + {file = "sqlalchemy-2.0.42-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3cb3ec67cc08bea54e06b569398ae21623534a7b1b23c258883a7c696ae10df"}, + {file = "sqlalchemy-2.0.42-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e87e6a5ef6f9d8daeb2ce5918bf5fddecc11cae6a7d7a671fcc4616c47635e01"}, + {file = "sqlalchemy-2.0.42-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b718011a9d66c0d2f78e1997755cd965f3414563b31867475e9bc6efdc2281d"}, + {file = "sqlalchemy-2.0.42-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:16d9b544873fe6486dddbb859501a07d89f77c61d29060bb87d0faf7519b6a4d"}, + {file = "sqlalchemy-2.0.42-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21bfdf57abf72fa89b97dd74d3187caa3172a78c125f2144764a73970810c4ee"}, + {file = "sqlalchemy-2.0.42-cp312-cp312-win32.whl", hash = "sha256:78b46555b730a24901ceb4cb901c6b45c9407f8875209ed3c5d6bcd0390a6ed1"}, + {file = "sqlalchemy-2.0.42-cp312-cp312-win_amd64.whl", hash = "sha256:4c94447a016f36c4da80072e6c6964713b0af3c8019e9c4daadf21f61b81ab53"}, + {file = "sqlalchemy-2.0.42-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:941804f55c7d507334da38133268e3f6e5b0340d584ba0f277dd884197f4ae8c"}, + {file = "sqlalchemy-2.0.42-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d3d06a968a760ce2aa6a5889fefcbdd53ca935735e0768e1db046ec08cbf01"}, + {file = "sqlalchemy-2.0.42-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cf10396a8a700a0f38ccd220d940be529c8f64435c5d5b29375acab9267a6c9"}, + {file = "sqlalchemy-2.0.42-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9cae6c2b05326d7c2c7c0519f323f90e0fb9e8afa783c6a05bb9ee92a90d0f04"}, + {file = "sqlalchemy-2.0.42-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f50f7b20677b23cfb35b6afcd8372b2feb348a38e3033f6447ee0704540be894"}, + {file = "sqlalchemy-2.0.42-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d88a1c0d66d24e229e3938e1ef16ebdbd2bf4ced93af6eff55225f7465cf350"}, + {file = "sqlalchemy-2.0.42-cp313-cp313-win32.whl", hash = "sha256:45c842c94c9ad546c72225a0c0d1ae8ef3f7c212484be3d429715a062970e87f"}, + {file = "sqlalchemy-2.0.42-cp313-cp313-win_amd64.whl", hash = "sha256:eb9905f7f1e49fd57a7ed6269bc567fcbbdac9feadff20ad6bd7707266a91577"}, + {file = "sqlalchemy-2.0.42-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ed5a6959b1668d97a32e3fd848b485f65ee3c05a759dee06d90e4545a3c77f1e"}, + {file = "sqlalchemy-2.0.42-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2ddbaafe32f0dd12d64284b1c3189104b784c9f3dba8cc1ba7e642e2b14b906f"}, + {file = "sqlalchemy-2.0.42-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37f4f42568b6c656ee177b3e111d354b5dda75eafe9fe63492535f91dfa35829"}, + {file = "sqlalchemy-2.0.42-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb57923d852d38671a17abda9a65cc59e3e5eab51fb8307b09de46ed775bcbb8"}, + {file = "sqlalchemy-2.0.42-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:437c2a8b0c780ff8168a470beb22cb4a25e1c63ea6a7aec87ffeb07aa4b76641"}, + {file = "sqlalchemy-2.0.42-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:480f7df62f0b3ad6aa011eefa096049dc1770208bb71f234959ee2864206eefe"}, + {file = "sqlalchemy-2.0.42-cp38-cp38-win32.whl", hash = "sha256:d119c80c614d62d32e236ae68e21dd28a2eaf070876b2f28a6075d5bae54ef3f"}, + {file = "sqlalchemy-2.0.42-cp38-cp38-win_amd64.whl", hash = "sha256:be3a02f963c8d66e28bb4183bebab66dc4379701d92e660f461c65fecd6ff399"}, + {file = "sqlalchemy-2.0.42-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:78548fd65cd76d4c5a2e6b5f245d7734023ee4de33ee7bb298f1ac25a9935e0d"}, + {file = "sqlalchemy-2.0.42-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cf4bf5a174d8a679a713b7a896470ffc6baab78e80a79e7ec5668387ffeccc8b"}, + {file = "sqlalchemy-2.0.42-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8c7ff7ba08b375f8a8fa0511e595c9bdabb5494ec68f1cf69bb24e54c0d90f2"}, + {file = "sqlalchemy-2.0.42-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b3c117f65d64e806ce5ce9ce578f06224dc36845e25ebd2554b3e86960e1aed"}, + {file = "sqlalchemy-2.0.42-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:27e4a7b3a7a61ff919c2e7caafd612f8626114e6e5ebbe339de3b5b1df9bc27e"}, + {file = "sqlalchemy-2.0.42-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b01e0dd39f96aefda5ab002d8402db4895db871eb0145836246ce0661635ce55"}, + {file = "sqlalchemy-2.0.42-cp39-cp39-win32.whl", hash = "sha256:49362193b1f43aa158deebf438062d7b5495daa9177c6c5d0f02ceeb64b544ea"}, + {file = "sqlalchemy-2.0.42-cp39-cp39-win_amd64.whl", hash = "sha256:636ec3dc83b2422a7ff548d0f8abf9c23742ca50e2a5cdc492a151eac7a0248b"}, + {file = "sqlalchemy-2.0.42-py3-none-any.whl", hash = "sha256:defcdff7e661f0043daa381832af65d616e060ddb54d3fe4476f51df7eaa1835"}, + {file = "sqlalchemy-2.0.42.tar.gz", hash = "sha256:160bedd8a5c28765bd5be4dec2d881e109e33b34922e50a3b881a7681773ac5f"}, +] + +[package.dependencies] +greenlet = {version = ">=1", optional = true, markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\") or extra == \"asyncio\""} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] +aioodbc = ["aioodbc", "greenlet (>=1)"] +aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + +[[package]] +name = "starlette" +version = "0.47.2" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b"}, + {file = "starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, + {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, + {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "uvicorn" +version = "0.35.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"}, + {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} +h11 = ">=0.8" +httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +uvloop = {version = ">=0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} + +[package.extras] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "uvloop" +version = "0.21.0" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f"}, + {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d"}, + {file = "uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26"}, + {file = "uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb"}, + {file = "uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f"}, + {file = "uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c"}, + {file = "uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8"}, + {file = "uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0"}, + {file = "uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e"}, + {file = "uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb"}, + {file = "uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6"}, + {file = "uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d"}, + {file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c"}, + {file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2"}, + {file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d"}, + {file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc"}, + {file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb"}, + {file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f"}, + {file = "uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281"}, + {file = "uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af"}, + {file = "uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6"}, + {file = "uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816"}, + {file = "uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc"}, + {file = "uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553"}, + {file = "uvloop-0.21.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:17df489689befc72c39a08359efac29bbee8eee5209650d4b9f34df73d22e414"}, + {file = "uvloop-0.21.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc09f0ff191e61c2d592a752423c767b4ebb2986daa9ed62908e2b1b9a9ae206"}, + {file = "uvloop-0.21.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0ce1b49560b1d2d8a2977e3ba4afb2414fb46b86a1b64056bc4ab929efdafbe"}, + {file = "uvloop-0.21.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e678ad6fe52af2c58d2ae3c73dc85524ba8abe637f134bf3564ed07f555c5e79"}, + {file = "uvloop-0.21.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:460def4412e473896ef179a1671b40c039c7012184b627898eea5072ef6f017a"}, + {file = "uvloop-0.21.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:10da8046cc4a8f12c91a1c39d1dd1585c41162a15caaef165c2174db9ef18bdc"}, + {file = "uvloop-0.21.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c097078b8031190c934ed0ebfee8cc5f9ba9642e6eb88322b9958b649750f72b"}, + {file = "uvloop-0.21.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:46923b0b5ee7fc0020bef24afe7836cb068f5050ca04caf6b487c513dc1a20b2"}, + {file = "uvloop-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53e420a3afe22cdcf2a0f4846e377d16e718bc70103d7088a4f7623567ba5fb0"}, + {file = "uvloop-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88cb67cdbc0e483da00af0b2c3cdad4b7c61ceb1ee0f33fe00e09c81e3a6cb75"}, + {file = "uvloop-0.21.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:221f4f2a1f46032b403bf3be628011caf75428ee3cc204a22addf96f586b19fd"}, + {file = "uvloop-0.21.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d1f581393673ce119355d56da84fe1dd9d2bb8b3d13ce792524e1607139feff"}, + {file = "uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3"}, +] + +[package.extras] +dev = ["Cython (>=3.0,<4.0)", "setuptools (>=60)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["aiohttp (>=3.10.5)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] + +[[package]] +name = "watchfiles" +version = "1.1.0" +description = "Simple, modern and high performance file watching and code reload in python." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "watchfiles-1.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:27f30e14aa1c1e91cb653f03a63445739919aef84c8d2517997a83155e7a2fcc"}, + {file = "watchfiles-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3366f56c272232860ab45c77c3ca7b74ee819c8e1f6f35a7125556b198bbc6df"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8412eacef34cae2836d891836a7fff7b754d6bcac61f6c12ba5ca9bc7e427b68"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df670918eb7dd719642e05979fc84704af913d563fd17ed636f7c4783003fdcc"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7642b9bc4827b5518ebdb3b82698ada8c14c7661ddec5fe719f3e56ccd13c97"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:199207b2d3eeaeb80ef4411875a6243d9ad8bc35b07fc42daa6b801cc39cc41c"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a479466da6db5c1e8754caee6c262cd373e6e6c363172d74394f4bff3d84d7b5"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:935f9edd022ec13e447e5723a7d14456c8af254544cefbc533f6dd276c9aa0d9"}, + {file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8076a5769d6bdf5f673a19d51da05fc79e2bbf25e9fe755c47595785c06a8c72"}, + {file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:86b1e28d4c37e89220e924305cd9f82866bb0ace666943a6e4196c5df4d58dcc"}, + {file = "watchfiles-1.1.0-cp310-cp310-win32.whl", hash = "sha256:d1caf40c1c657b27858f9774d5c0e232089bca9cb8ee17ce7478c6e9264d2587"}, + {file = "watchfiles-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:a89c75a5b9bc329131115a409d0acc16e8da8dfd5867ba59f1dd66ae7ea8fa82"}, + {file = "watchfiles-1.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c9649dfc57cc1f9835551deb17689e8d44666315f2e82d337b9f07bd76ae3aa2"}, + {file = "watchfiles-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:406520216186b99374cdb58bc48e34bb74535adec160c8459894884c983a149c"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45350fd1dc75cd68d3d72c47f5b513cb0578da716df5fba02fff31c69d5f2d"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11ee4444250fcbeb47459a877e5e80ed994ce8e8d20283857fc128be1715dac7"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bda8136e6a80bdea23e5e74e09df0362744d24ffb8cd59c4a95a6ce3d142f79c"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b915daeb2d8c1f5cee4b970f2e2c988ce6514aace3c9296e58dd64dc9aa5d575"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed8fc66786de8d0376f9f913c09e963c66e90ced9aa11997f93bdb30f7c872a8"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe4371595edf78c41ef8ac8df20df3943e13defd0efcb732b2e393b5a8a7a71f"}, + {file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b7c5f6fe273291f4d414d55b2c80d33c457b8a42677ad14b4b47ff025d0893e4"}, + {file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7738027989881e70e3723c75921f1efa45225084228788fc59ea8c6d732eb30d"}, + {file = "watchfiles-1.1.0-cp311-cp311-win32.whl", hash = "sha256:622d6b2c06be19f6e89b1d951485a232e3b59618def88dbeda575ed8f0d8dbf2"}, + {file = "watchfiles-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:48aa25e5992b61debc908a61ab4d3f216b64f44fdaa71eb082d8b2de846b7d12"}, + {file = "watchfiles-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:00645eb79a3faa70d9cb15c8d4187bb72970b2470e938670240c7998dad9f13a"}, + {file = "watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179"}, + {file = "watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f"}, + {file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4"}, + {file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f"}, + {file = "watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd"}, + {file = "watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47"}, + {file = "watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6"}, + {file = "watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30"}, + {file = "watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c"}, + {file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b"}, + {file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb"}, + {file = "watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9"}, + {file = "watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7"}, + {file = "watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5"}, + {file = "watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1"}, + {file = "watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20"}, + {file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef"}, + {file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb"}, + {file = "watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297"}, + {file = "watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e"}, + {file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b"}, + {file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259"}, + {file = "watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f"}, + {file = "watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147"}, + {file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8"}, + {file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db"}, + {file = "watchfiles-1.1.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:865c8e95713744cf5ae261f3067861e9da5f1370ba91fc536431e29b418676fa"}, + {file = "watchfiles-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42f92befc848bb7a19658f21f3e7bae80d7d005d13891c62c2cd4d4d0abb3433"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa0cc8365ab29487eb4f9979fd41b22549853389e22d5de3f134a6796e1b05a4"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90ebb429e933645f3da534c89b29b665e285048973b4d2b6946526888c3eb2c7"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c588c45da9b08ab3da81d08d7987dae6d2a3badd63acdb3e206a42dbfa7cb76f"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c55b0f9f68590115c25272b06e63f0824f03d4fc7d6deed43d8ad5660cabdbf"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd17a1e489f02ce9117b0de3c0b1fab1c3e2eedc82311b299ee6b6faf6c23a29"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da71945c9ace018d8634822f16cbc2a78323ef6c876b1d34bbf5d5222fd6a72e"}, + {file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:51556d5004887045dba3acdd1fdf61dddea2be0a7e18048b5e853dcd37149b86"}, + {file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04e4ed5d1cd3eae68c89bcc1a485a109f39f2fd8de05f705e98af6b5f1861f1f"}, + {file = "watchfiles-1.1.0-cp39-cp39-win32.whl", hash = "sha256:c600e85f2ffd9f1035222b1a312aff85fd11ea39baff1d705b9b047aad2ce267"}, + {file = "watchfiles-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:3aba215958d88182e8d2acba0fdaf687745180974946609119953c0e112397dc"}, + {file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a6fd40bbb50d24976eb275ccb55cd1951dfb63dbc27cae3066a6ca5f4beabd5"}, + {file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9f811079d2f9795b5d48b55a37aa7773680a5659afe34b54cc1d86590a51507d"}, + {file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2726d7bfd9f76158c84c10a409b77a320426540df8c35be172444394b17f7ea"}, + {file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df32d59cb9780f66d165a9a7a26f19df2c7d24e3bd58713108b41d0ff4f929c6"}, + {file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0ece16b563b17ab26eaa2d52230c9a7ae46cf01759621f4fbbca280e438267b3"}, + {file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:51b81e55d40c4b4aa8658427a3ee7ea847c591ae9e8b81ef94a90b668999353c"}, + {file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2bcdc54ea267fe72bfc7d83c041e4eb58d7d8dc6f578dfddb52f037ce62f432"}, + {file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792"}, + {file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7b3443f4ec3ba5aa00b0e9fa90cf31d98321cbff8b925a7c7b84161619870bc9"}, + {file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7049e52167fc75fc3cc418fc13d39a8e520cbb60ca08b47f6cedb85e181d2f2a"}, + {file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54062ef956807ba806559b3c3d52105ae1827a0d4ab47b621b31132b6b7e2866"}, + {file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a7bd57a1bb02f9d5c398c0c1675384e7ab1dd39da0ca50b7f09af45fa435277"}, + {file = "watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575"}, +] + +[package.dependencies] +anyio = ">=3.0.0" + +[[package]] +name = "websockets" +version = "15.0.1" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"}, + {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"}, + {file = "websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c"}, + {file = "websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256"}, + {file = "websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf"}, + {file = "websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85"}, + {file = "websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597"}, + {file = "websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9"}, + {file = "websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4"}, + {file = "websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa"}, + {file = "websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880"}, + {file = "websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411"}, + {file = "websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123"}, + {file = "websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f"}, + {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, +] + +[metadata] +lock-version = "2.1" +python-versions = "^3.13" +content-hash = "d253f94effe23854dbffbf1e16defdfdb083cc91a5bc487c4a970b4f0f300c87" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cd8f69b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,64 @@ +[project] +name = "tp-fastapi" +version = "0.1.0" +description = "TP FastAPI" +authors = [ + {name = "Your Name",email = "you@example.com"} +] +readme = "README.md" +classifiers = [ + "Programming Language :: Python :: 3.13", +] +keywords = ["fastapi", "web"] + +exclude = [ + { path = "tests", format = "wheel" } +] +requires-python = ">=3.13" + +[tool.poetry] +package-mode = false + +[tool.poetry.dependencies] +python = "^3.13" +fastapi = "^0.116.1" +uvicorn = { version = "^0.35.0", extras = [ "standard" ] } +gunicorn = "^23.0.0" +sqlalchemy = {extras = ["asyncio"], version = "^2.0.42"} +pydantic = {extras = ["email"], version = "^2.11.7"} +python-dotenv = "^1.0.1" +pydantic-settings = "^2.10.1" +aiosqlite = "^0.21.0" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.4.1" +pytest-cov = "^6.2.1" +pytest-asyncio = "^1.1.0" +pytest-mock = "^3.14.1" +httpx = "^0.28.1" +aiosqlite = "^0.21.0" +coverage = { version="*", extras=["toml"]} + +[tool.pytest.ini_options] +asyncio_mode = "auto" +pythonpath = "src" +testpaths = "tests" +addopts = "-v -s" + +[tool.black] +line-length = 120 + +[tool.pycln] +all = true + +[tool.isort] +line_length = 120 +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/resources/tp_fastapi.drawio b/resources/tp_fastapi.drawio new file mode 100644 index 0000000..058c4c6 --- /dev/null +++ b/resources/tp_fastapi.drawio @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/tp_fastapi_uml.png b/resources/tp_fastapi_uml.png new file mode 100644 index 0000000000000000000000000000000000000000..ddd0532269b03c26f3e7bc088aad7a172f51da59 GIT binary patch literal 54720 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∋c(`uAPNthhm+vnAAf4sf1r^>3 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 L3|XvmF7m$ssg#y8 literal 0 HcmV?d00001 diff --git a/src/app/__init__.py b/src/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/api/__init__.py b/src/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/api/deps.py b/src/app/api/deps.py new file mode 100644 index 0000000..0f1d2df --- /dev/null +++ b/src/app/api/deps.py @@ -0,0 +1,10 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from app.db.session import AsyncSessionLocal + +# Injection de dépendances de la session SQLAlchemy (ORM) +async def get_db() -> AsyncSession: + """ + Dependency that provides a database session for a single request. + """ + async with AsyncSessionLocal() as session: + yield session diff --git a/src/app/api/exception_handlers.py b/src/app/api/exception_handlers.py new file mode 100644 index 0000000..b1554da --- /dev/null +++ b/src/app/api/exception_handlers.py @@ -0,0 +1,43 @@ +from fastapi import Request, status +from fastapi.responses import JSONResponse +from app.core.exceptions import NotFoundBLLException, ValidationBLLException, DALException, BLLException + +async def not_found_bll_exception_handler(request: Request, exc: NotFoundBLLException): + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={"detail": str(exc)}, + ) + +async def validation_bll_exception_handler(request: Request, exc: ValidationBLLException): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"detail": str(exc)}, + ) + +async def bll_exception_handler(request: Request, exc: BLLException): + """Gestionnaire pour les autres erreurs métier non spécifiques.""" + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"detail": str(exc)}, + ) + +async def dal_exception_handler(request: Request, exc: DALException): + # En production, on devrait logger l'exception originale: exc.original_exception + print(f"DAL Error: {exc.original_exception}") # Pour le debug + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Une erreur interne est survenue au niveau de la base de données."}, + ) + +# Note : cela évite de devoir gérer les exceptions de type DAL et BLL dans les routeurs FastAPI directement : +# +# @router.get("/movies/{movie_id}", response_model=movie_schemas.Movie) +# async def read_movie(movie_id: int, db: AsyncSession = Depends(get_db)): +# """Récupère les détails d'un film spécifique par son ID.""" +# try: +# return await movie_service.get_movie_by_id(db=db, movie_id=movie_id) +# except NotFoundError as e: +# raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) +# except DALException as e: +# # Logguer l'erreur originale (e.original_exception) serait une bonne pratique ici +# raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Erreur interne du serveur.") diff --git a/src/app/api/routers/__init__.py b/src/app/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/api/routers/genres.py b/src/app/api/routers/genres.py new file mode 100644 index 0000000..bfbbcfb --- /dev/null +++ b/src/app/api/routers/genres.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter, Depends +from typing import List +from sqlalchemy.ext.asyncio import AsyncSession +import app.schemas.genre as genre_schemas +from app.api.deps import get_db +import app.services.genre as genre_service + +router = APIRouter() + +@router.get("/genres/", response_model=List[genre_schemas.GenreRead]) +async def read_genres(db: AsyncSession = Depends(get_db)): + return await genre_service.get_genres(db) \ No newline at end of file diff --git a/src/app/api/routers/movies.py b/src/app/api/routers/movies.py new file mode 100644 index 0000000..dc73d7a --- /dev/null +++ b/src/app/api/routers/movies.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, Depends +from typing import List +from sqlalchemy.ext.asyncio import AsyncSession +import app.schemas.movie as movie_schemas +from app.api.deps import get_db +import app.services.movie as movie_service +from fastapi import status + +router = APIRouter() + +@router.post("/movies/", response_model=movie_schemas.MovieRead, status_code=status.HTTP_201_CREATED) +async def create_movie(movie: movie_schemas.MovieCreate, db: AsyncSession = Depends(get_db)): + """Crée un nouveau film.""" + return await movie_service.create_movie(db=db, movie=movie) + +@router.get("/movies/", response_model=List[movie_schemas.MovieRead]) +async def read_movies(skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db)): + """Récupère une liste de films.""" + return await movie_service.get_movies(db, skip=skip, limit=limit) + +@router.get("/movies/{movie_id}", response_model=movie_schemas.MovieRead) +async def read_movie(movie_id: int, db: AsyncSession = Depends(get_db)): + """Récupère les détails d'un film spécifique par son ID.""" + return await movie_service.get_movie_by_id(db=db, movie_id=movie_id) \ No newline at end of file diff --git a/src/app/api/routers/opinions.py b/src/app/api/routers/opinions.py new file mode 100644 index 0000000..1ca72b1 --- /dev/null +++ b/src/app/api/routers/opinions.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +import app.schemas.opinion as opinion_schemas +from app.api.deps import get_db +import app.services.opinion as opinion_service +from fastapi import Response, status + +router = APIRouter() + +@router.post("/movies/{movie_id}/opinions/", response_model=opinion_schemas.OpinionRead, status_code=status.HTTP_201_CREATED) +async def create_opinion_for_movie( + movie_id: int, opinion: opinion_schemas.OpinionCreate, db: AsyncSession = Depends(get_db) +): + """Ajoute un avis à un film spécifique.""" + return await opinion_service.create_opinion(db=db, movie_id=movie_id, opinion=opinion) + +@router.delete("/opinions/{opinion_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_opinion(opinion_id: int, db: AsyncSession = Depends(get_db)): + """Supprime un avis par son ID.""" + await opinion_service.delete_opinion(db=db, opinion_id=opinion_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/src/app/api/routers/participants.py b/src/app/api/routers/participants.py new file mode 100644 index 0000000..c4960a9 --- /dev/null +++ b/src/app/api/routers/participants.py @@ -0,0 +1,28 @@ +from typing import List +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession +from app.api.deps import get_db +from app.schemas.participant import ParticipantRead, ParticipantCreate, ParticipantUpdate +from app.services import participant as participant_service + +router = APIRouter() + +@router.get("/participants/", response_model=List[ParticipantRead]) +async def read_participants(db: AsyncSession = Depends(get_db)): + """Récupère une liste de tous les participants.""" + return await participant_service.get_participants(db) + +@router.post("/participants/", response_model=ParticipantRead, status_code=status.HTTP_201_CREATED) +async def create_participant( + participant: ParticipantCreate, db: AsyncSession = Depends(get_db) +): + """Crée un nouveau participant (acteur ou réalisateur).""" + return await participant_service.create_participant(db=db, participant=participant) + + +@router.patch("/participants/{participant_id}", response_model=ParticipantRead) +async def update_participant( + participant_id: int, participant_data: ParticipantUpdate, db: AsyncSession = Depends(get_db) +): + """Met à jour un participant existant.""" + return await participant_service.update_participant(db, participant_id, participant_data) diff --git a/src/app/core/__init__.py b/src/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/core/config.py b/src/app/core/config.py new file mode 100644 index 0000000..a124a87 --- /dev/null +++ b/src/app/core/config.py @@ -0,0 +1,38 @@ +from typing import List + +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """ + Classe de configuration qui charge les variables d'environnement. + """ + # Configuration du modèle Pydantic + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=True # Respecte la casse des variables + ) + + # Paramètres du projet + PROJECT_NAME: str = "FastAPI Project" + API_V1_STR: str = "/api/v1" + + # Configuration de la base de données + # Le type hint `str` est suffisant, mais des types plus stricts peuvent être utilisés + DATABASE_URL: str = "sqlite+aiosqlite:///./local_dev.db" + + # Configuration de la sécurité (JWT) + # SECRET_KEY: str + # ALGORITHM: str = "HS256" + #ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + + # Configuration CORS + # Pydantic va automatiquement convertir la chaîne de caractères séparée par des virgules + # en une liste de chaînes de caractères. + BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = [] + + +# Création d'une instance unique des paramètres qui sera importée dans le reste de l'application +settings = Settings() \ No newline at end of file diff --git a/src/app/core/exceptions.py b/src/app/core/exceptions.py new file mode 100644 index 0000000..6bc9523 --- /dev/null +++ b/src/app/core/exceptions.py @@ -0,0 +1,25 @@ + +class BaseAppException(Exception): + """Exception de base pour l'application.""" + pass + +class DALException(BaseAppException): + """Exception levée pour les erreurs de la couche d'accès aux données (DAL).""" + def __init__(self, message: str, original_exception: Exception = None): + self.message = message + self.original_exception = original_exception + super().__init__(self.message) + +class BLLException(BaseAppException): + """Exception de base pour les erreurs de la couche métier (BLL).""" + pass + +class NotFoundBLLException(BLLException): + """Levée lorsqu'une ressource n'est pas trouvée.""" + def __init__(self, resource_name: str, resource_id: int | str): + message = f"{resource_name} avec l'ID '{resource_id}' non trouvé." + super().__init__(message) + +class ValidationBLLException(BLLException): + """Levée pour les erreurs de validation des règles métier.""" + pass \ No newline at end of file diff --git a/src/app/db/__init__.py b/src/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/db/seeding.py b/src/app/db/seeding.py new file mode 100644 index 0000000..5325a2f --- /dev/null +++ b/src/app/db/seeding.py @@ -0,0 +1,71 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from app.models.genre import Genre +from app.models.participant import Participant +from app.models.member import Member +from app.models.movie import Movie +from app.models.opinion import Opinion + + +async def seed_db(session: AsyncSession): + """ + Préremplit la base de données avec des données initiales si elle est vide. + """ + + # 1. Vérifier si la BDD est déja remplie (ex: en comptant les genres) + result = await session.execute(select(Genre)) + if result.scalars().first() is not None: + print("La base de données contient déja des données. Seeding annulé.") + return + + print("Base de données vide. Début du seeding...") + + # 2. Créer les Genres + genre1 = Genre(label="Science-Fiction") + genre2 = Genre(label="Comédie") + genre3 = Genre(label="Drame") + session.add_all([genre1, genre2, genre3]) + await session.commit() # Commit pour que les objets aient un ID + + # 3. Créer les Participants (Acteurs/Réalisateurs) + director1 = Participant(first_name="Christopher", last_name="Nolan") + actor1 = Participant(first_name="Leonardo", last_name="DiCaprio") + actor2 = Participant(first_name="Marion", last_name="Cotillard") + session.add_all([director1, actor1, actor2]) + await session.commit() + + # 4. Créer un Membre + member1 = Member( + login="testuser", + password="hashed_password_here", # En réalité, il faudrait hasher ce mot de passe + is_admin=False, + first_name="Test", + last_name="User" + ) + session.add(member1) + await session.commit() + + # 5. Créer un Film + movie1 = Movie( + title="Inception", + year=2010, + duration=148, + synopsis="Un voleur qui s'approprie des secrets... (etc)", + director_id=director1.id, + genre_id=genre1.id, + actors=[actor1, actor2] # La relation M2M est gérée par SQLAlchemy + ) + session.add(movie1) + await session.commit() + + # 6. Créer un Avis + opinion1 = Opinion( + note=5, + comment="Incroyable !", + member_id=member1.id, + movie_id=movie1.id + ) + session.add(opinion1) + await session.commit() + + print(f"Film '{movie1.title}' et ses relations ont été ajoutés.") diff --git a/src/app/db/session.py b/src/app/db/session.py new file mode 100644 index 0000000..9b65dbc --- /dev/null +++ b/src/app/db/session.py @@ -0,0 +1,21 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker +from app.core.config import settings + +# Configuration conditionnelle de l'engine +if "sqlite+aiosqlite" in settings.DATABASE_URL: + # Configuration pour SQLite basé sur un fichier + engine = create_async_engine( + settings.DATABASE_URL, + echo=True, + connect_args={"check_same_thread": False} # Requis pour SQLite + ) +else: + # Configuration par défaut pour les autres BDD (ex: mysql+asyncmy, postgresql+asyncpg, etc.) + engine = create_async_engine(settings.DATABASE_URL, echo=True) + +# SessionMaker pour créer des sessions asynchrones +# expire_on_commit=False est important pour utiliser les objets après le commit dans un contexte async +AsyncSessionLocal = sessionmaker( + autocommit=False, autoflush=False, bind=engine, class_=AsyncSession, expire_on_commit=False +) diff --git a/src/app/main.py b/src/app/main.py new file mode 100644 index 0000000..140aed4 --- /dev/null +++ b/src/app/main.py @@ -0,0 +1,65 @@ +from fastapi import FastAPI +from contextlib import asynccontextmanager +from app.core.config import settings +from fastapi.middleware.cors import CORSMiddleware +from app.db.session import engine, AsyncSessionLocal +from app.api.routers.opinions import router as opinions_router +from app.api.routers.movies import router as movies_router +from app.api.routers.genres import router as genres_router +from app.api.routers.participants import router as participants_router +from app.models import Base +from app.core.exceptions import NotFoundBLLException, ValidationBLLException, DALException, BLLException +from app.api.exception_handlers import ( + not_found_bll_exception_handler, + validation_bll_exception_handler, + dal_exception_handler, + bll_exception_handler, +) +from app.db.seeding import seed_db + +# Crée les tables dans la BDD au démarrage (pour le développement) +# En production, on utiliserait un outil de migration comme Alembic. +@asynccontextmanager +async def lifespan(myapp: FastAPI): + async with engine.begin() as conn: + #await conn.run_sync(Base.metadata.drop_all) Optionnel: pour repartir de zéro + await conn.run_sync(Base.metadata.create_all) + async with AsyncSessionLocal() as session: + await seed_db(session) + yield + +app = FastAPI( + title="API Filmothèque", + description="Une API pour gérer une collection de films, réalisée avec FastAPI et SQLAlchemy async.", + version="1.0.0", + lifespan=lifespan +) + + +if settings.BACKEND_CORS_ORIGINS: + app.add_middleware( + CORSMiddleware, + allow_origins=[str(origin).rstrip('/') for origin in settings.BACKEND_CORS_ORIGINS], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + +app.include_router(opinions_router, prefix=settings.API_V1_STR, tags=["Opinions"]) +app.include_router(movies_router, prefix=settings.API_V1_STR, tags=["Movies"]) +app.include_router(genres_router, prefix=settings.API_V1_STR, tags=["Genres"]) +app.include_router(participants_router, prefix=settings.API_V1_STR, tags=["Participants"]) + +# Ajouter les gestionnaires d'exceptions +app.add_exception_handler(NotFoundBLLException, not_found_bll_exception_handler) +app.add_exception_handler(ValidationBLLException, validation_bll_exception_handler) +app.add_exception_handler(BLLException, bll_exception_handler) # Gestionnaire plus générique +app.add_exception_handler(DALException, dal_exception_handler) + +@app.get("/", tags=["Root"]) +def read_root(): + """ + Un endpoint simple pour vérifier que l'API est en ligne. + """ + return {"message": "Welcome to this fantastic API!"} + diff --git a/src/app/models/__init__.py b/src/app/models/__init__.py new file mode 100644 index 0000000..5eef325 --- /dev/null +++ b/src/app/models/__init__.py @@ -0,0 +1,13 @@ +# Eviter les erreurs liés à l'importation circulaire +# Exemple : sqlalchemy.exc.InvalidRequestError: When initializing mapper Mapper[Movie(movies)], expression 'Genre' failed to locate a name ('Genre'). If this is a class name, consider adding this relationship() to the class after both dependent classes have been defined + +from .base_class import Base +from .genre import Genre +from .member import Member +from .movie import Movie +from .opinion import Opinion +from .participant import Participant +from .person import Person + +# L'import des associations (ex : personnefilm) n'est en général pas nécessaire ici car elles sont gérées dans les modèles eux-mêmes + diff --git a/src/app/models/base_class.py b/src/app/models/base_class.py new file mode 100644 index 0000000..5bec245 --- /dev/null +++ b/src/app/models/base_class.py @@ -0,0 +1,3 @@ +from sqlalchemy.orm import declarative_base +Base = declarative_base() + diff --git a/src/app/models/genre.py b/src/app/models/genre.py new file mode 100644 index 0000000..d943914 --- /dev/null +++ b/src/app/models/genre.py @@ -0,0 +1,13 @@ +from sqlalchemy import ( + String, +) +from sqlalchemy.orm import ( + Mapped, + mapped_column, +) +from .base_class import Base + +class Genre(Base): + __tablename__ = "genres" + id: Mapped[int] = mapped_column(primary_key=True) + label: Mapped[str] = mapped_column(String(255)) diff --git a/src/app/models/member.py b/src/app/models/member.py new file mode 100644 index 0000000..fd79ed9 --- /dev/null +++ b/src/app/models/member.py @@ -0,0 +1,33 @@ +from sqlalchemy import ( + String, + Boolean, + ForeignKey, +) +from sqlalchemy.orm import ( + Mapped, + mapped_column, + relationship +) +from typing import List +from .person import Person + +class Member(Person): + __tablename__ = "members" + + # La clé primaire est aussi une clé étrangère vers la table parente + id: Mapped[int] = mapped_column(ForeignKey("persons.id"), primary_key=True) + + # Champs spécifiques à Member + login: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + password: Mapped[str] = mapped_column(String(255), nullable=False) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False) + + # La relation vers Opinion + opinions: Mapped[List["Opinion"]] = relationship(back_populates="member") + + __mapper_args__ = { + "polymorphic_identity": "member", + } + + def __repr__(self) -> str: + return f"Member(id={self.id}, login='{self.login}')" diff --git a/src/app/models/movie.py b/src/app/models/movie.py new file mode 100644 index 0000000..190c7c9 --- /dev/null +++ b/src/app/models/movie.py @@ -0,0 +1,36 @@ +from sqlalchemy import ( + Integer, + String, + ForeignKey, + Text +) +from sqlalchemy.orm import ( + Mapped, + mapped_column, + relationship +) +from typing import List + +from .base_class import Base +from .movie_actors import movie_actors_association_table + +class Movie(Base): + __tablename__ = "movies" + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(250), nullable=False) + year: Mapped[int] = mapped_column(Integer) + duration: Mapped[int] = mapped_column(Integer) + synopsis: Mapped[str] = mapped_column(Text) + + director_id: Mapped[int] = mapped_column(ForeignKey("participants.id")) + genre_id: Mapped[int] = mapped_column(ForeignKey("genres.id")) + + director: Mapped["Participant"] = relationship(foreign_keys=[director_id]) + genre: Mapped["Genre"] = relationship() + actors: Mapped[List["Participant"]] = relationship( + secondary=movie_actors_association_table + ) + opinions: Mapped[List["Opinion"]] = relationship( + back_populates="movie", cascade="all, delete-orphan" + ) + diff --git a/src/app/models/movie_actors.py b/src/app/models/movie_actors.py new file mode 100644 index 0000000..89f9740 --- /dev/null +++ b/src/app/models/movie_actors.py @@ -0,0 +1,10 @@ +from sqlalchemy import Column, ForeignKey, Table +from .base_class import Base + +# Table d'association pour la relation Many-to-Many entre Film et Acteur +movie_actors_association_table = Table( + "movie_actors_association", + Base.metadata, + Column("movie_id", ForeignKey("movies.id"), primary_key=True), + Column("participant_id", ForeignKey("participants.id"), primary_key=True), +) \ No newline at end of file diff --git a/src/app/models/opinion.py b/src/app/models/opinion.py new file mode 100644 index 0000000..8da5d04 --- /dev/null +++ b/src/app/models/opinion.py @@ -0,0 +1,23 @@ +from sqlalchemy import ( + Integer, + ForeignKey, + Text +) +from sqlalchemy.orm import ( + Mapped, + mapped_column, + relationship +) +from .base_class import Base + +class Opinion(Base): + __tablename__ = "opinions" + id: Mapped[int] = mapped_column(primary_key=True) + note: Mapped[int] = mapped_column(Integer) + comment: Mapped[str] = mapped_column(Text) + + member_id: Mapped[int] = mapped_column(ForeignKey("members.id")) + movie_id: Mapped[int] = mapped_column(ForeignKey("movies.id")) + + member: Mapped["Member"] = relationship(back_populates="opinions") + movie: Mapped["Movie"] = relationship(back_populates="opinions") diff --git a/src/app/models/participant.py b/src/app/models/participant.py new file mode 100644 index 0000000..f506568 --- /dev/null +++ b/src/app/models/participant.py @@ -0,0 +1,21 @@ +from sqlalchemy import ( + ForeignKey, +) +from sqlalchemy.orm import ( + Mapped, + mapped_column +) +from .person import Person + +class Participant(Person): + __tablename__ = "participants" + + # La clé primaire est aussi une clé étrangère vers la table parente + id: Mapped[int] = mapped_column(ForeignKey("persons.id"), primary_key=True) + + __mapper_args__ = { + "polymorphic_identity": "participant", + } + + def __repr__(self) -> str: + return f"Participant(id={self.id}, name='{self.first_name} {self.last_name}')" diff --git a/src/app/models/person.py b/src/app/models/person.py new file mode 100644 index 0000000..10221dd --- /dev/null +++ b/src/app/models/person.py @@ -0,0 +1,21 @@ +from sqlalchemy import ( + String, +) +from sqlalchemy.orm import ( + Mapped, + mapped_column +) +from .base_class import Base + +class Person(Base): + __tablename__ = "persons" + id: Mapped[int] = mapped_column(primary_key=True) + last_name: Mapped[str] = mapped_column(String(255)) + first_name: Mapped[str] = mapped_column(String(255)) + # colonne discriminante pour la hiérarchie d'héritage + type: Mapped[str] = mapped_column(String(50)) + + __mapper_args__ = { + "polymorphic_identity": "person", + "polymorphic_on": "type", + } \ No newline at end of file diff --git a/src/app/repositories/__init__.py b/src/app/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/repositories/genre.py b/src/app/repositories/genre.py new file mode 100644 index 0000000..599fd97 --- /dev/null +++ b/src/app/repositories/genre.py @@ -0,0 +1,14 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +import app.models.genre as models +from sqlalchemy.exc import SQLAlchemyError +from app.core.exceptions import DALException + +async def get_genres(db: AsyncSession): + """Récupère tous les genres de la base de données.""" + try: + stmt = select(models.Genre) + result = await db.execute(stmt) + return result.scalars().all() + except SQLAlchemyError as e: + raise DALException("Erreur lors de la récupération des genres", original_exception=e) diff --git a/src/app/repositories/member.py b/src/app/repositories/member.py new file mode 100644 index 0000000..5d7119f --- /dev/null +++ b/src/app/repositories/member.py @@ -0,0 +1,13 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from app.models.member import Member +from sqlalchemy.exc import SQLAlchemyError +from app.core.exceptions import DALException + +async def get_member(db: AsyncSession, member_id: int): + """Récupère un membre par son ID.""" + try: + result = await db.execute(select(Member).where(Member.id == member_id)) + return result.scalar_one_or_none() + except SQLAlchemyError as e: + raise DALException(f"Erreur lors de la récupération du membre {member_id}", original_exception=e) diff --git a/src/app/repositories/movie.py b/src/app/repositories/movie.py new file mode 100644 index 0000000..f075c64 --- /dev/null +++ b/src/app/repositories/movie.py @@ -0,0 +1,78 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy.orm import selectinload +from sqlalchemy.exc import SQLAlchemyError +from app.core.exceptions import DALException + +from app.models.movie import Movie +from app.models.opinion import Opinion +import app.schemas.movie as schemas +import app.models.person as person_models + +async def get_movie(db: AsyncSession, movie_id: int): + """Récupère un film par son ID avec ses relations.""" + try: + stmt = ( + select(Movie) + .where(Movie.id == movie_id) + .options( + selectinload(Movie.genre), + selectinload(Movie.director), + selectinload(Movie.actors), + selectinload(Movie.opinions), + selectinload(Movie.opinions).selectinload(Opinion.member) + ) + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + except SQLAlchemyError as e: + raise DALException(f"Erreur lors de la récupération du film {movie_id}", original_exception=e) + +async def get_movies(db: AsyncSession, skip: int = 0, limit: int = 100): + """Récupère une liste de films avec leurs relations principales.""" + try: + stmt = ( + select(Movie) + .offset(skip).limit(limit) + .options( + selectinload(Movie.genre), + selectinload(Movie.director), + selectinload(Movie.actors), + selectinload(Movie.opinions), + selectinload(Movie.opinions).selectinload(Opinion.member) + ) + .order_by(Movie.title) + ) + result = await db.execute(stmt) + return result.scalars().all() + except SQLAlchemyError as e: + raise DALException("Erreur lors de la récupération des films", original_exception=e) + +async def create_movie(db: AsyncSession, movie: schemas.MovieCreate): + """Crée un nouveau film.""" + try: + # Récupérer les objets acteurs à partir de leurs IDs + actors_result = await db.execute( + select(person_models.Person).where(person_models.Person.id.in_(movie.actors_ids)) + ) + actors = actors_result.scalars().all() + + # Créer l'instance du film + db_movie = Movie( + title=movie.title, + year=movie.year, + duration=movie.duration, + synopsis=movie.synopsis, + genre_id=movie.genre_id, + director_id=movie.director_id, + actors=actors + ) + db.add(db_movie) + await db.commit() + + # Recharger les relations pour les retourner dans la réponse + await db.refresh(db_movie) + return await get_movie(db, db_movie.id) + except SQLAlchemyError as e: + await db.rollback() # IMPORTANT: annuler la transaction en cas d'erreur + raise DALException("Erreur lors de la création du film", original_exception=e) diff --git a/src/app/repositories/opinion.py b/src/app/repositories/opinion.py new file mode 100644 index 0000000..799fc8d --- /dev/null +++ b/src/app/repositories/opinion.py @@ -0,0 +1,60 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy.orm import selectinload + +import app.schemas.opinion as schemas +import app.models.opinion as models +from sqlalchemy.exc import SQLAlchemyError +from app.core.exceptions import DALException + +async def create_opinion_for_movie(db: AsyncSession, opinion: schemas.OpinionCreate, movie_id: int): + """Crée un avis pour un film donné.""" + try: + db_opinion = models.Opinion(**opinion.model_dump(), movie_id=movie_id) + db.add(db_opinion) + await db.commit() + await db.refresh(db_opinion) + + query = ( + select(models.Opinion) + .where(models.Opinion.id == db_opinion.id) + .options( + selectinload(models.Opinion.member) + ) + ) + result = await db.execute(query) + return result.scalars().one() + except SQLAlchemyError as e: + await db.rollback() # IMPORTANT: annuler la transaction en cas d'erreur + raise DALException("Erreur lors de la création de l'avis", original_exception=e) + +async def get_opinion(db: AsyncSession, opinion_id: int): + """Récupère un avis par son ID.""" + try: + stmt = select(models.Opinion).where(models.Opinion.id == opinion_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + except SQLAlchemyError as e: + raise DALException(f"Erreur lors de la récupération de l'avis {opinion_id}", original_exception=e) + +async def delete_opinion_by_id(db: AsyncSession, opinion_id: int): + """Supprime un avis de la base de données.""" + try: + db_opinion = await get_opinion(db, opinion_id=opinion_id) + if db_opinion: + await db.delete(db_opinion) + await db.commit() + return db_opinion + except SQLAlchemyError as e: + await db.rollback() # IMPORTANT: annuler la transaction en cas d'erreur + raise DALException(f"Erreur lors de la suppression de l'avis {opinion_id}", original_exception=e) + +async def delete_opinion(db: AsyncSession, db_opinion: models.Opinion): + """Supprime un avis de la base de données.""" + try: + if db_opinion: + await db.delete(db_opinion) + await db.commit() + except SQLAlchemyError as e: + await db.rollback() # IMPORTANT: annuler la transaction en cas d'erreur + raise DALException(f"Erreur lors de la suppression de l'avis {db_opinion.id}", original_exception=e) diff --git a/src/app/repositories/participant.py b/src/app/repositories/participant.py new file mode 100644 index 0000000..42341a8 --- /dev/null +++ b/src/app/repositories/participant.py @@ -0,0 +1,60 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from app.models.participant import Participant as ParticipantModel +from app.schemas.participant import ParticipantCreate, ParticipantUpdate +from sqlalchemy.exc import SQLAlchemyError +from app.core.exceptions import DALException + + +async def get_participant(db: AsyncSession, participant_id: int): + """Récupère un participant par son ID.""" + try: + result = await db.execute(select(ParticipantModel).where(ParticipantModel.id == participant_id)) + return result.scalar_one_or_none() + except SQLAlchemyError as e: + raise DALException(f"Erreur lors de la récupération du participant {participant_id}", original_exception=e) + +async def get_participants(db: AsyncSession): + """Récupère tous les participants de la base de données.""" + try: + result = await db.execute(select(ParticipantModel).order_by(ParticipantModel.last_name)) + return result.scalars().all() + except SQLAlchemyError as e: + raise DALException("Erreur lors de la récupération des participants", original_exception=e) + +async def create_participant(db: AsyncSession, participant: ParticipantCreate) -> ParticipantModel: + """Crée un nouveau participant.""" + try: + db_participant = ParticipantModel( + first_name=participant.first_name, + last_name=participant.last_name + ) + db.add(db_participant) + await db.commit() + await db.refresh(db_participant) + return db_participant + except SQLAlchemyError as e: + await db.rollback() # IMPORTANT: annuler la transaction en cas d'erreur + raise DALException("Erreur lors de la création du participant", original_exception=e) + +async def update_participant( + db: AsyncSession, participant_id: int, participant_data: ParticipantUpdate +) -> ParticipantModel: + """Met à jour les informations d'un participant existant.""" + try: + db_participant = await get_participant(db, participant_id) + if not db_participant: + return None + + # Met à jour les champs si la valeur n'est pas None + update_data = participant_data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(db_participant, key, value) + + await db.commit() + await db.refresh(db_participant) + return db_participant + except SQLAlchemyError as e: + await db.rollback() + raise DALException(f"Erreur lors de la mise à jour du participant {participant_id}", original_exception=e) + diff --git a/src/app/schemas/__init__.py b/src/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/schemas/genre.py b/src/app/schemas/genre.py new file mode 100644 index 0000000..17212e0 --- /dev/null +++ b/src/app/schemas/genre.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, ConfigDict + +class GenreBase(BaseModel): + label: str + + +class GenreCreate(GenreBase): + pass + + +class GenreRead(GenreBase): + id: int + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/src/app/schemas/member.py b/src/app/schemas/member.py new file mode 100644 index 0000000..778f3f1 --- /dev/null +++ b/src/app/schemas/member.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, ConfigDict + +class MemberBase(BaseModel): + login: str + +class MemberRead(MemberBase): + id: int + + model_config = ConfigDict(from_attributes=True) diff --git a/src/app/schemas/movie.py b/src/app/schemas/movie.py new file mode 100644 index 0000000..500d21c --- /dev/null +++ b/src/app/schemas/movie.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel, ConfigDict +from typing import List, Optional +from .person import PersonRead +from .genre import GenreRead +from .opinion import OpinionRead + +class MovieBase(BaseModel): + title: str + year: int + duration: Optional[int] = None + synopsis: Optional[str] = None + + +# Schéma pour la création : on utilise les IDs pour les relations +class MovieCreate(MovieBase): + genre_id: int + director_id: int + actors_ids: List[int] = [] + + +# Schéma complet pour la lecture : on imbrique les objets complets +class MovieRead(MovieBase): + id: int + genre: GenreRead + director: PersonRead + actors: List[PersonRead] = [] + opinions: List[OpinionRead] = [] + + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/src/app/schemas/opinion.py b/src/app/schemas/opinion.py new file mode 100644 index 0000000..3184bae --- /dev/null +++ b/src/app/schemas/opinion.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, ConfigDict + +from app.schemas.member import MemberRead + + +class OpinionBase(BaseModel): + note: int + comment: str + + +class OpinionCreate(OpinionBase): + member_id: int + # movie_id n'est pas nécessaire ici car il vient déjà de l'URL + pass + + +class OpinionRead(OpinionBase): + id: int + movie_id: int + member: MemberRead + model_config = ConfigDict(from_attributes=True) + diff --git a/src/app/schemas/participant.py b/src/app/schemas/participant.py new file mode 100644 index 0000000..7d2910d --- /dev/null +++ b/src/app/schemas/participant.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, ConfigDict +from typing import Optional +from .person import PersonBase + + +# Schéma pour la création d'un Participant +class ParticipantCreate(PersonBase): + pass + + +# Schéma pour la mise à jour d'un Participant +class ParticipantUpdate(BaseModel): + # Les champs sont optionnels pour permettre des mises à jour partielles (PATCH) + last_name: Optional[str] = None + first_name: Optional[str] = None + + model_config = ConfigDict(extra="forbid") # Empêche l'ajout de champs non définis + + +# Schéma complet pour la lecture d'un Participant +class ParticipantRead(PersonBase): + id: int + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/src/app/schemas/person.py b/src/app/schemas/person.py new file mode 100644 index 0000000..ac91af2 --- /dev/null +++ b/src/app/schemas/person.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, ConfigDict +from typing import Optional + +class PersonBase(BaseModel): + last_name: str + first_name: Optional[str] = None + + +class PersonCreate(PersonBase): + pass + + +class PersonRead(PersonBase): + id: int + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/src/app/services/__init__.py b/src/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/services/genre.py b/src/app/services/genre.py new file mode 100644 index 0000000..f073da7 --- /dev/null +++ b/src/app/services/genre.py @@ -0,0 +1,9 @@ +from typing import List +from sqlalchemy.ext.asyncio import AsyncSession + +import app.repositories.genre as genre_repository +import app.models.genre as genre_models + +async def get_genres(db: AsyncSession) -> List[genre_models.Genre]: + """Service pour récupérer une liste de genres.""" + return await genre_repository.get_genres(db) diff --git a/src/app/services/member.py b/src/app/services/member.py new file mode 100644 index 0000000..b19a739 --- /dev/null +++ b/src/app/services/member.py @@ -0,0 +1,11 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from app.repositories import member as member_repository +from app.models.member import Member as MemberModel +from app.core.exceptions import NotFoundBLLException + +async def get_member_by_id(db: AsyncSession, member_id: int) -> MemberModel: + """Service pour récupérer un membre par son ID.""" + db_member = await member_repository.get_member(db, member_id=member_id) + if db_member is None: + raise NotFoundBLLException(resource_name="Membre", resource_id=member_id) + return db_member \ No newline at end of file diff --git a/src/app/services/movie.py b/src/app/services/movie.py new file mode 100644 index 0000000..a72cc08 --- /dev/null +++ b/src/app/services/movie.py @@ -0,0 +1,43 @@ +import datetime +from typing import List +from sqlalchemy.ext.asyncio import AsyncSession + +import app.repositories.movie as movie_repository +import app.schemas.movie as movie_schemas +import app.models.movie as movie_models +from app.core.exceptions import NotFoundBLLException, ValidationBLLException + +async def get_movies(db: AsyncSession, skip: int, limit: int) -> List[movie_models.Movie]: + """Service pour récupérer une liste de films.""" + # Actuellement un simple relais, mais la logique complexe (filtres, etc.) irait ici. + return await movie_repository.get_movies(db, skip=skip, limit=limit) + +async def get_movie_by_id(db: AsyncSession, movie_id: int) -> movie_models.Movie: + """Service pour récupérer un film par son ID.""" + db_movie = await movie_repository.get_movie(db, movie_id=movie_id) + if db_movie is None: + # Utiliser notre exception métier, pas une exception HTTP, pour des raisons de séparation des préoccupations. + raise NotFoundBLLException(resource_name="Film", resource_id=movie_id) + return db_movie + +async def create_movie(db: AsyncSession, movie: movie_schemas.MovieCreate) -> movie_models.Movie: + """Service pour créer un nouveau film.""" + + # Règle métier 1 : le titre ne peut pas être vide + if not movie.title or not movie.title.strip(): + raise ValidationBLLException("Le titre du film ne peut pas être vide.") + + # Règle métier 2 : l'année doit être réaliste + current_year = datetime.date.today().year + if not (1888 <= movie.year <= current_year + 5): + raise ValidationBLLException(f"L'année du film doit être comprise entre 1888 et {current_year + 5}.") + + # Règle métier 3 : valider l'existence des entités liées + # Ici, on pourrait aussi vérifier que genre_id, director_id et actors_ids existent + # en appelant leurs services/repositories respectifs pour une validation complète. + # await genre_service.get_genre_by_id(db, movie.genre_id) + # await participant_service.get_participant_by_id(db, movie.director_id) + # await participant_service.check_participants_ids(db, movie.actors_ids) + + return await movie_repository.create_movie(db=db, movie=movie) + diff --git a/src/app/services/opinion.py b/src/app/services/opinion.py new file mode 100644 index 0000000..71f4d20 --- /dev/null +++ b/src/app/services/opinion.py @@ -0,0 +1,40 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +import app.services.movie as movie_service +import app.services.member as member_service +import app.repositories.opinion as opinion_repository +import app.schemas.opinion as opinion_schemas +import app.models.opinion as opinion_models + +from app.core.exceptions import NotFoundBLLException, ValidationBLLException + +async def create_opinion( + db: AsyncSession, *, movie_id: int, opinion: opinion_schemas.OpinionCreate +) -> opinion_models.Opinion: + """ + Service pour créer un avis pour un film. + Contient la logique métier : vérifier que le film existe et que la note est valide. + """ + + # Règle métier 1 : On ne peut pas noter un film qui n'existe pas. + # On utilise le service movie qui lève déjà une NotFoundError propre. + await movie_service.get_movie_by_id(db, movie_id=movie_id) + + # Règle métier 2 : L'auteur de l'avis (Membre) doit exister. + await member_service.get_member_by_id(db, member_id=opinion.member_id) + + # Règle métier 3 : La note doit être dans un intervalle valide (ex: 0 à 5) + if not (0 <= opinion.note <= 5): + raise ValidationBLLException("La note doit être comprise entre 0 et 5.") + + # Appel au repositories pour la création pure + return await opinion_repository.create_opinion_for_movie(db=db, opinion=opinion, movie_id=movie_id) + + +async def delete_opinion(db: AsyncSession, opinion_id: int) -> opinion_models.Opinion: + """Service pour supprimer un avis.""" + db_opinion = await opinion_repository.get_opinion(db, opinion_id=opinion_id) + if db_opinion is None: + raise NotFoundBLLException(resource_name="Avis", resource_id=opinion_id) + await opinion_repository.delete_opinion(db, db_opinion=db_opinion) + return db_opinion diff --git a/src/app/services/participant.py b/src/app/services/participant.py new file mode 100644 index 0000000..40b837d --- /dev/null +++ b/src/app/services/participant.py @@ -0,0 +1,23 @@ +from typing import List +from sqlalchemy.ext.asyncio import AsyncSession +from app.repositories import participant as participant_repository +from app.schemas.participant import ParticipantCreate, ParticipantUpdate +from app.models.participant import Participant as ParticipantModel +from app.core.exceptions import NotFoundBLLException + +async def get_participants(db: AsyncSession) -> List[ParticipantModel]: + """Service pour récupérer une liste de participants.""" + return await participant_repository.get_participants(db) + +async def create_participant(db: AsyncSession, participant: ParticipantCreate) -> ParticipantModel: + """Service pour créer un nouveau participant.""" + return await participant_repository.create_participant(db, participant=participant) + +async def update_participant( + db: AsyncSession, participant_id: int, participant_data: ParticipantUpdate +) -> ParticipantModel: + """Service pour mettre à jour un participant.""" + db_participant = await participant_repository.update_participant(db, participant_id, participant_data) + if db_participant is None: + raise NotFoundBLLException(resource_name="Participant", resource_id=participant_id) + return db_participant diff --git a/src/local_dev.db b/src/local_dev.db new file mode 100644 index 0000000000000000000000000000000000000000..d1d5e80781b069c94d321907850ab560993860d4 GIT binary patch literal 40960 zcmeI*?Qh#e90%|_J5Np@jDQhURn(iRYKhp&ld4Hbm6m1hsFtNHO@Y0N+~l^Nl-Qo_ zZtKgIQSpLTh`QHb?6OS4009U<00Izz00d4iursgaOXV_s^p0v4w)@^pfxXmh}R}Ct#w9+z`uQ!9V%Cn-YtYs`2 zEu&dCR#}wl9lgZC+a(tW5~KXZ>duGxd}(o!KFWt+QT@!WZ@XT{^gO#OOkWD7r56&Q zX}JR6W7pgK+MSL!ZOBCH;N=-unf z@6a7{)3?LFJ82r7_tNHQJ60}Vnwp|tTz=jfUYOg+>2OPO4$Hl5n3A_&=sqKT~htSAJZ7u#2q5Nzkus3jv! zCmN{7nyznpe#bHg@g6jSc1O4;=JB_j=Ov7G!BMti^&t&A|Mrb=4Wc-WUX?`s zhY_6K-sU>*r?=+ecb<@ds{SLpJwH0bVMfi8nW@>j?TdcjbbB{VSMIRK zPC;QxmXyo0*F@cPUMTqTkiw=sw6I}%8@$&UUi8tCM*?_ofj`xM zC-Q*-0uX=z1Rwwb2tWV=5P$##AOL}vM_^nn)8Qe3OmzMKQ= z00Izz00bZa0SG_<0uXrB1r${wiXxv>A<_B&dqm%R)m5nAw|BC$KU*E^C|7Wt5Q7H&O00Izz00bZa0SG_<0uX?}DFrf$s!=sV z<*O20|E~ns|L@}t0H@@{Vi14;1Rwwb2tWV=5P$##AOL~0D3Hr&l&VTrjx;4Y|NjZm zpPa?2qD~Ni00bZa0SG_<0uX=z1Rwwb2>d&NH))R0IjUvl)dH@)Wv=%*JD>R*hr+3D literal 0 HcmV?d00001 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/test_movies_api.py b/tests/api/test_movies_api.py new file mode 100644 index 0000000..501cd70 --- /dev/null +++ b/tests/api/test_movies_api.py @@ -0,0 +1,69 @@ +import pytest +from httpx import AsyncClient + +# Marqueur pour indiquer à pytest que ce sont des tests asynchrones +pytestmark = pytest.mark.asyncio + + +# Données de test réutilisables +@pytest.fixture +async def test_data(db_session): + """Fixture pour insérer des données de test initiales.""" + from app.models import Genre, Participant + + # 1. Créer les objets + genre = Genre(label="Science-Fiction") + director = Participant(first_name="Denis", last_name="Villeneuve") + + db_session.add_all([genre, director]) + await db_session.commit() + + # 2. Rafraîchir les objets pour obtenir les ID générés par la BDD + await db_session.refresh(genre) + await db_session.refresh(director) + + # 3. Renvoyer uniquement les ID, pas les objets entiers + return {"genre_id": genre.id, "director_id": director.id} + + +async def test_create_movie_success(test_client: AsyncClient, test_data): + """Vérifie la création réussie d'un film via l'API.""" + response = await test_client.post( + "/api/v1/movies/", + json={ + "title": "Dune", + "year": 2021, + "duration": 155, + "synopsis": "A mythic and emotionally charged hero's journey.", + "genre_id": test_data["genre_id"], + "director_id": test_data["director_id"], + "actors_ids": [] + }, + ) + assert response.status_code == 201 + data = response.json() + assert data["title"] == "Dune" + assert "id" in data + assert data["genre"]["label"] == "Science-Fiction" + + +async def test_create_movie_validation_error(test_client: AsyncClient, test_data): + """Vérifie que l'API retourne une erreur 400 pour des données invalides.""" + response = await test_client.post( + "/api/v1/movies/", + json={ + "title": "Future Movie", + "year": 1800, # Année invalide + "genre_id": test_data["genre_id"], + "director_id": test_data["director_id"], + }, + ) + assert response.status_code == 400 + assert "L'année du film doit être comprise entre" in response.json()["detail"] + + +async def test_read_movie_not_found(test_client: AsyncClient): + """Vérifie que l'API retourne une erreur 404 pour un film inexistant.""" + response = await test_client.get("/api/v1/movies/999") + assert response.status_code == 404 + assert "Film avec l'ID '999' non trouvé." in response.json()["detail"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b200a3b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,109 @@ +import pytest +import pytest_asyncio +from typing import AsyncGenerator +import asyncio + +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker +from httpx import AsyncClient, ASGITransport + +from app.main import app +from app.api.deps import get_db +from app.models import Base, Genre, Participant, Member + +# URL pour une base de données SQLite en mémoire +TEST_DATABASE_URL = "sqlite+aiosqlite:///file:memdb_tp_filmotheque?mode=memory&cache=shared" + +# Créer un moteur de BDD de test +engine = create_async_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False}) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=AsyncSession) + + +@pytest.fixture(scope="session") +def event_loop(): + """Crée une instance de la boucle d'événements pour toute la session de test.""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest_asyncio.fixture(scope="function") +async def db_session() -> AsyncGenerator[AsyncSession, None]: + """ + Fixture pour fournir une session de BDD de test isolée pour chaque test. + Recrée les tables à chaque fois pour garantir un état propre. + """ + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + async with TestingSessionLocal() as session: + try: + yield session + finally: + await session.rollback() + await session.close() + + +@pytest.fixture(scope="function") +def override_get_db(db_session: AsyncSession): + """Fixture pour surcharger la dépendance get_db de l'application.""" + + async def _override_get_db(): + yield db_session + + return _override_get_db + + +@pytest_asyncio.fixture(scope="function") +async def test_client(override_get_db) -> AsyncGenerator[AsyncClient, None]: + """Fixture pour le client HTTP de FastAPI.""" + app.dependency_overrides[get_db] = override_get_db + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + app.dependency_overrides.clear() + + +@pytest_asyncio.fixture(scope="function") +async def test_data(db_session: AsyncSession): + """ + Fixture pour insérer des données de test initiales. + """ + # 1. Créer les objets + genre_sf = Genre(label="Science-Fiction") + genre_action = Genre(label="Action") + director_nolan = Participant(first_name="Christopher", last_name="Nolan") + actor_leo = Participant(first_name="Leonardo", last_name="DiCaprio") + + member_user = Member( + first_name="Test", + last_name="User", + login="testuser", + password="password", + is_admin=False + ) + + db_session.add_all([genre_sf, genre_action, director_nolan, actor_leo, member_user]) + await db_session.commit() + + # Rafraîchir les objets après le commit pour charger leurs ID + await db_session.refresh(genre_sf) + await db_session.refresh(genre_action) + await db_session.refresh(director_nolan) + await db_session.refresh(actor_leo) + await db_session.refresh(member_user) + + # --- CORRECTION FINALE --- + # Ne pas retourner les objets SQLAlchemy eux-mêmes, mais seulement leurs ID + # et les valeurs simples. + return { + "genre_sf_id": genre_sf.id, + "genre_action_id": genre_action.id, + "director_nolan_id": director_nolan.id, + "director_nolan_lastname": director_nolan.last_name, + "actor_leo_id": actor_leo.id, + "actor_leo_lastname": actor_leo.last_name, + "member_user_id": member_user.id, + "member_user_login": member_user.login + } \ No newline at end of file diff --git a/tests/enonce/etape2_test_schemas.py b/tests/enonce/etape2_test_schemas.py new file mode 100644 index 0000000..5351a7f --- /dev/null +++ b/tests/enonce/etape2_test_schemas.py @@ -0,0 +1,60 @@ +import pytest +from pydantic import ValidationError + +try: + from app.schemas.person import PersonBase, PersonRead + from app.schemas.participant import ParticipantCreate, ParticipantRead, ParticipantUpdate + from app.schemas.opinion import OpinionBase, OpinionCreate, OpinionRead + from app.schemas.member import MemberRead + + SCHEMAS_LOADED = True +except ImportError as e: + print(f"Échec de l'import des schémas : {e}") + SCHEMAS_LOADED = False + + +@pytest.mark.skipif(not SCHEMAS_LOADED, reason="Schémas (Person, Participant, Opinion) non trouvés ou import échoué") +def test_person_schemas(): + """Teste les schémas Person (Base et Read) - TODO Étape 2.""" + person_data = {"first_name": "John", "last_name": "Doe"} + base = PersonBase(**person_data) + assert base.last_name == "Doe" + + read = PersonRead(id=1, **person_data) + assert read.id == 1 + + +@pytest.mark.skipif(not SCHEMAS_LOADED, reason="Schémas (Person, Participant, Opinion) non trouvés ou import échoué") +def test_participant_schemas(): + """Teste les schémas Participant (Create, Update, Read) - TODO Étape 2.""" + participant_data = {"first_name": "Jane", "last_name": "Smith"} + create = ParticipantCreate(**participant_data) + assert create.last_name == "Smith" + + update_data = {"first_name": "Janet"} + update = ParticipantUpdate(**update_data) + assert update.first_name == "Janet" + assert update.last_name is None + + # Teste que des champs inconnus lèvent une erreur (extra="forbid") + with pytest.raises(ValidationError): + ParticipantUpdate(first_name="Test", unknown_field="error") + + +@pytest.mark.skipif(not SCHEMAS_LOADED, reason="Schémas (Person, Participant, Opinion) non trouvés ou import échoué") +def test_opinion_schemas(): + """Teste les schémas Opinion (Base, Create, Read) - TODO Étape 2.""" + opinion_data = {"note": 5, "comment": "Excellent!"} + base = OpinionBase(**opinion_data) + assert base.note == 5 + + create_data = {"member_id": 1, **opinion_data} + create = OpinionCreate(**create_data) + assert create.member_id == 1 + + # Mock d'un membre pour le schéma de lecture + mock_member = MemberRead(id=1, login="testuser") + read_data = {"id": 10, "movie_id": 20, "member": mock_member, **opinion_data} + read = OpinionRead(**read_data) + assert read.id == 10 + assert read.member.login == "testuser" \ No newline at end of file diff --git a/tests/enonce/etape3_test_repositories.py b/tests/enonce/etape3_test_repositories.py new file mode 100644 index 0000000..a607678 --- /dev/null +++ b/tests/enonce/etape3_test_repositories.py @@ -0,0 +1,123 @@ +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.repositories import movie as movie_repository +from app.repositories import opinion as opinion_repository +from app.repositories import genre as genre_repository +from app.schemas.movie import MovieCreate +from app.schemas.opinion import OpinionCreate +from app.models import Genre, Participant, Member, Movie, Opinion + +# Marqueur pour indiquer à pytest que ce sont des tests asynchrones +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +async def repo_test_data(db_session: AsyncSession): + """Fixture pour insérer des données de test pour les tests de repository.""" + + # 1. Créer tous les objets ORM manuellement + genre = Genre(label="Science-Fiction") + director = Participant(first_name="Denis", last_name="Villeneuve") + member = Member( + first_name="Repo", + last_name="Tester", + login="repo_user", + password="pwd" + ) + + db_session.add_all([genre, director, member]) + await db_session.flush() + + # 2. Créer les objets dépendants (Movie, Opinion) manuellement + + # Ajouter des valeurs pour les champs NOT NULL (duration et synopsis) + db_movie = Movie( + title="Dune", + year=2021, + duration=155, + synopsis="Un film sur le sable et les vers.", + genre_id=genre.id, + director_id=director.id + ) + + db_session.add(db_movie) + await db_session.flush() # Flusher pour obtenir l'ID du film + + db_opinion = Opinion( + note=5, + comment="Génial", + member_id=member.id, + movie_id=db_movie.id + ) + db_session.add(db_opinion) + + # 3. Faire un SEUL commit à la fin pour tout sauvegarder + await db_session.commit() + + # 4. Rafraîchir les objets pour être sûr qu'ils sont chargés pour les tests + await db_session.refresh(genre) + await db_session.refresh(director) + await db_session.refresh(member) + await db_session.refresh(db_movie) + await db_session.refresh(db_opinion) + + # Rafraîchir aussi les relations du film + await db_session.refresh(db_movie, attribute_names=["genre", "director", "opinions"]) + + return { + "movie": db_movie, + "opinion": db_opinion, + "genre": genre, + "director": director, + "member": member + } + + +async def test_get_movies_repository(db_session: AsyncSession, repo_test_data): + """Teste le TODO 'get_movies' dans movie_repository - Étape 3.""" + + # 1. Appeler la fonction à tester + movies = await movie_repository.get_movies(db_session, skip=0, limit=10) + + # 2. Vérifier les résultats + assert isinstance(movies, list) + assert len(movies) == 1 + assert movies[0].title == "Dune" + # Vérifier que les relations sont chargées (problème N+1) + assert movies[0].genre is not None + assert movies[0].genre.label == "Science-Fiction" + assert movies[0].director is not None + assert movies[0].director.last_name == "Villeneuve" + assert movies[0].opinions is not None + assert len(movies[0].opinions) == 1 + assert movies[0].opinions[0].comment == "Génial" + + +async def test_get_delete_opinion_repository(db_session: AsyncSession, repo_test_data): + """Teste les TODO 'get_opinion' et 'delete_opinion_by_id' - Étape 3.""" + + opinion_id = repo_test_data["opinion"].id + + # 1. Tester get_opinion (TODO) + fetched_opinion = await opinion_repository.get_opinion(db_session, opinion_id) + assert fetched_opinion is not None + assert fetched_opinion.id == opinion_id + assert fetched_opinion.comment == "Génial" + + # 2. Tester delete_opinion_by_id (TODO) + deleted_opinion = await opinion_repository.delete_opinion_by_id(db_session, opinion_id) + assert deleted_opinion is not None + assert deleted_opinion.id == opinion_id + + # 3. Vérifier que l'avis a bien été supprimé + fetched_again = await opinion_repository.get_opinion(db_session, opinion_id) + assert fetched_again is None + + +async def test_get_genres_repository(db_session: AsyncSession, repo_test_data): + """Teste 'get_genres' (déjà implémenté, mais bon à avoir).""" + genres = await genre_repository.get_genres(db_session) + assert isinstance(genres, list) + assert len(genres) >= 1 + assert genres[0].label == "Science-Fiction" \ No newline at end of file diff --git a/tests/enonce/etape4_test_services.py b/tests/enonce/etape4_test_services.py new file mode 100644 index 0000000..47f4850 --- /dev/null +++ b/tests/enonce/etape4_test_services.py @@ -0,0 +1,98 @@ +import pytest +from unittest.mock import AsyncMock, patch + +from app.services import genre as genre_service +from app.services import participant as participant_service +from app.services import opinion as opinion_service +from app.schemas.participant import ParticipantUpdate +from app.schemas.opinion import OpinionCreate +from app.core.exceptions import NotFoundBLLException, ValidationBLLException + +# Marqueur pour indiquer à pytest que ce sont des tests asynchrones +pytestmark = pytest.mark.asyncio + + +async def test_get_genres_service(mocker): + """Teste le TODO 'get_genres' (service) - Étape 4.""" + # 1. Arrange + # Simuler le repository pour qu'il retourne une liste + mock_repo = mocker.patch("app.repositories.genre.get_genres", new_callable=AsyncMock) + mock_repo.return_value = [{"id": 1, "label": "Action"}] + + # 2. Act + result = await genre_service.get_genres(db=AsyncMock()) + + # 3. Assert + mock_repo.assert_called_once() + assert len(result) == 1 + assert result[0]["label"] == "Action" + + +async def test_update_participant_not_found(mocker): + """Teste que update_participant (service) lève NotFoundBLLException - Étape 4.""" + # 1. Arrange + # Simuler le repository pour qu'il retourne None + mock_repo = mocker.patch("app.repositories.participant.update_participant", new_callable=AsyncMock) + mock_repo.return_value = None + + update_data = ParticipantUpdate(first_name="Test") + + # 2. Act & 3. Assert + with pytest.raises(NotFoundBLLException, match="Participant avec l'ID '999' non trouvé"): + await participant_service.update_participant( + db=AsyncMock(), + participant_id=999, + participant_data=update_data + ) + + +async def test_create_opinion_service_validation(mocker): + """Teste la validation (note) dans create_opinion (service) - Étape 4.""" + # 1. Arrange + # Simuler le service de film (nécessaire pour la validation) + mocker.patch("app.services.movie.get_movie_by_id", new_callable=AsyncMock) + # Simuler le service de membre (maintenant aussi nécessaire) + mocker.patch("app.services.member.get_member_by_id", new_callable=AsyncMock) + + # Données d'opinion avec une note invalide + opinion_data = OpinionCreate(note=10, comment="Trop haut!", member_id=1) + + # 2. Act & 3. Assert + with pytest.raises(ValidationBLLException, match="La note doit être comprise entre 0 et 5"): + await opinion_service.create_opinion( + db=AsyncMock(), + movie_id=1, + opinion=opinion_data + ) + + +async def test_create_opinion_service_movie_not_found(mocker): + """Teste que create_opinion lève NotFound si le film n'existe pas - Étape 4.""" + # 1. Arrange + # Simuler le service de film pour qu'il lève l'exception + mocker.patch( + "app.services.movie.get_movie_by_id", + new_callable=AsyncMock, + side_effect=NotFoundBLLException(resource_name="Film", resource_id=999) + ) + opinion_data = OpinionCreate(note=5, comment="Valide", member_id=1) + + # 2. Act & 3. Assert + with pytest.raises(NotFoundBLLException, match="Film avec l'ID '999' non trouvé"): + await opinion_service.create_opinion( + db=AsyncMock(), + movie_id=999, + opinion=opinion_data + ) + + +async def test_delete_opinion_service_not_found(mocker): + """Teste que delete_opinion (service) lève NotFound - Étape 4.""" + # 1. Arrange + # Simuler le repository d'opinion pour qu'il retourne None + mock_repo = mocker.patch("app.repositories.opinion.get_opinion", new_callable=AsyncMock) + mock_repo.return_value = None + + # 2. Act & 3. Assert + with pytest.raises(NotFoundBLLException, match="Avis avec l'ID '999' non trouvé"): + await opinion_service.delete_opinion(db=AsyncMock(), opinion_id=999) \ No newline at end of file diff --git a/tests/enonce/etape5_test_api.py b/tests/enonce/etape5_test_api.py new file mode 100644 index 0000000..dde6e43 --- /dev/null +++ b/tests/enonce/etape5_test_api.py @@ -0,0 +1,112 @@ +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession +# CORRECTION : Importer le modèle Movie +from app.models import Movie + +# Marqueur pour indiquer à pytest que ce sont des tests asynchrones +pytestmark = pytest.mark.asyncio + + +async def test_read_genres_api(test_client: AsyncClient, test_data): + """Teste le TODO 'GET /genres/' (API) - Étape 5.""" + response = await test_client.get("/api/v1/genres/") + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) >= 2 # "Science-Fiction" et "Action" de la fixture + assert data[0]["label"] == "Science-Fiction" + assert data[1]["label"] == "Action" + + +async def test_participants_api_workflow(test_client: AsyncClient, test_data): + """ + Teste le workflow complet pour les participants : + - POST /participants/ (TODO) + - GET /participants/ (TODO) + - PATCH /participants/{id} (TODO) + """ + + # 1. Tester GET /participants/ (TODO) avec les données de la fixture + response_get_all = await test_client.get("/api/v1/participants/") + assert response_get_all.status_code == 200 + list_data = response_get_all.json() + assert len(list_data) == 2 # Nolan et DiCaprio + + # Utiliser les valeurs simples de la fixture test_data + assert list_data[0]["last_name"] == test_data["actor_leo_lastname"] # Trié par nom de famille + assert list_data[1]["last_name"] == test_data["director_nolan_lastname"] + + # 2. Tester POST /participants/ (TODO) + participant_data = {"first_name": "Greta", "last_name": "Gerwig"} + response_post = await test_client.post("/api/v1/participants/", json=participant_data) + + assert response_post.status_code == 201 + created_data = response_post.json() + assert created_data["first_name"] == "Greta" + participant_id = created_data["id"] + + # 3. Tester PATCH /participants/{id} (TODO) + patch_data = {"first_name": "G.", "last_name": "Gerwig-Baumbach"} + response_patch = await test_client.patch( + f"/api/v1/participants/{participant_id}", + json=patch_data + ) + assert response_patch.status_code == 200 + updated_data = response_patch.json() + assert updated_data["first_name"] == "G." + assert updated_data["last_name"] == "Gerwig-Baumbach" + + +async def test_opinions_api_workflow(test_client: AsyncClient, db_session: AsyncSession, test_data): + """ + Teste le workflow des avis : + - POST /movies/{id}/opinions/ (TODO) + - DELETE /opinions/{id} (déjà fourni, mais on teste) + """ + + # 1. Créer un film de test manuellement pour avoir un movie_id + + # Ajouter les champs NOT NULL (duration, synopsis) + movie = Movie( + title="Inception", + year=2010, + duration=148, + synopsis="Un film sur les rêves.", + + # Utiliser les ID simples de la fixture test_data + genre_id=test_data["genre_action_id"], + director_id=test_data["director_nolan_id"] + ) + + db_session.add(movie) + await db_session.commit() + await db_session.refresh(movie) + movie_id = movie.id + + # 2. Tester POST /movies/{id}/opinions/ (TODO) + opinion_data = { + "note": 5, + "comment": "Mind-blowing!", + "member_id": test_data["member_user_id"] + } + response_post = await test_client.post( + f"/api/v1/movies/{movie_id}/opinions/", + json=opinion_data + ) + + assert response_post.status_code == 201 + created_opinion = response_post.json() + assert created_opinion["comment"] == "Mind-blowing!" + assert created_opinion["member"]["login"] == test_data["member_user_login"] + + opinion_id = created_opinion["id"] + + # 3. Tester DELETE /opinions/{id} + response_delete = await test_client.delete(f"/api/v1/opinions/{opinion_id}") + assert response_delete.status_code == 204 # No Content + + # 4. Vérifier que la suppression lève un 404 si on réessaye + response_delete_again = await test_client.delete(f"/api/v1/opinions/{opinion_id}") + assert response_delete_again.status_code == 404 \ No newline at end of file diff --git a/tests/services/test_movie_service.py b/tests/services/test_movie_service.py new file mode 100644 index 0000000..e49e4a0 --- /dev/null +++ b/tests/services/test_movie_service.py @@ -0,0 +1,72 @@ +import pytest +from unittest.mock import AsyncMock + +from app.services import movie as movie_service +from app.schemas.movie import MovieCreate +from app.core.exceptions import NotFoundBLLException, ValidationBLLException + +# Marqueur pour indiquer à pytest que ce sont des tests asynchrones +pytestmark = pytest.mark.asyncio + + +async def test_get_movie_by_id_success(mocker): + """ + Vérifie que le service retourne un film si le repositories le trouve. + """ + # 1. Préparation (Arrange) + # On simule le repositories movie + mock_repo = mocker.patch("app.repositories.movie.get_movie", new_callable=AsyncMock) + + # On configure le mock pour qu'il retourne une fausse donnée + fake_movie_id = 1 + mock_repo.return_value = {"id": fake_movie_id, "title": "Fake Movie"} + + # 2. Action (Act) + # On appelle la fonction du service à tester + result = await movie_service.get_movie_by_id(db=AsyncMock(), movie_id=fake_movie_id) + + # 3. Assertion (Assert) + # On vérifie que le service a bien appelé le repositories + mock_repo.assert_called_once_with(mocker.ANY, movie_id=fake_movie_id) + + # On vérifie que le résultat est correct + assert result["id"] == fake_movie_id + + +async def test_get_movie_by_id_not_found(mocker): + """ + Vérifie que le service lève une exception NotFoundError si le repositories ne trouve rien. + """ + # 1. Arrange + # On simule le repositories pour qu'il retourne None + mock_repo = AsyncMock(return_value=None) + mocker.patch("app.repositories.movie.get_movie", new=mock_repo) + + # 2. Act & 3. Assert + # On s'attend à ce qu'une exception soit levée et on vérifie son type + with pytest.raises(NotFoundBLLException): + await movie_service.get_movie_by_id(db=AsyncMock(), movie_id=999) + + +async def test_create_movie_invalid_year(): + """ + Vérifie que le service lève une ValidationError pour une année invalide. + """ + # 1. Arrange + movie_data = MovieCreate(title="The Future Movie", year=3000, genre_id=1, director_id=1) + + # 2. Act & 3. Assert + with pytest.raises(ValidationBLLException, match="L'année du film doit être comprise entre"): + await movie_service.create_movie(db=AsyncMock(), movie=movie_data) + + +async def test_create_movie_empty_title(): + """ + Vérifie que le service lève une ValidationError pour un titre vide. + """ + # 1. Arrange + movie_data = MovieCreate(title=" ", year=2020, genre_id=1, director_id=1) + + # 2. Act & 3. Assert + with pytest.raises(ValidationBLLException, match="Le titre du film ne peut pas être vide."): + await movie_service.create_movie(db=AsyncMock(), movie=movie_data)