├── .gitignore ├── .python-version ├── README.md ├── app ├── __init__.py ├── database.py ├── main.py ├── models.py ├── schemas.py └── user.py ├── poetry.lock ├── pyproject.toml ├── pytest.ini ├── requirements.txt └── tests ├── __init__.py ├── conftest.py └── test_crud_api.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pytest_cache 3 | venv/ 4 | .venv 5 | # .env 6 | *.db 7 | *.idea -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12.2 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # User Service API Example 2 | 3 | ## Overview 4 | 5 | This is a simple User Service CRUD (Create, Read, Update, Delete) API built with FastAPI and SQLite. The API allows you to create, read, update, and delete users. It uses Pydantic models for request and response validation and SQLAlchemy for database operations. 6 | 7 | ## Architecture 8 | This project follows a clean architecture pattern, separating concerns to enhance maintainability and scalability. Here's a brief overview: 9 | 10 | - API Layer (FastAPI): Handles HTTP requests and responses, routing, and interaction with the service layer. 11 | - Service Layer: Contains business logic and communicates with the database layer. 12 | - Database Layer (SQLite): Manages data persistence and database operations. 13 | - Testing: Unit tests are written in Pytest to test the service layer functions. 14 | 15 | ## Getting Started 16 | 17 | ### Prerequisites and Dependencies 18 | - Python 3.12 19 | - FastAPI 20 | - SQLite 21 | - Uvicorn (for running the server) 22 | 23 | #### Poetry 24 | 25 | This project uses [Poetry](https://python-poetry.org/) for dependency management. 26 | 27 | If you're not familiar with Poetry, please follow [these instructions](https://python-poetry.org/docs/#installation) to install it. 28 | 29 | Once you've installed Poetry, you can install the dependencies using the following command: 30 | 31 | ```shell 32 | $ poetry install 33 | ``` 34 | 35 | Then run the below command to activate the virtual environment. 36 | 37 | ```shell 38 | $ poetry shell 39 | ``` 40 | 41 | #### Pip 42 | 43 | If you prefer using `pip`, you can create a virtual environment and then install the dependencies using the following command: 44 | 45 | ```shell 46 | $ pip install -r requirements.txt 47 | ``` 48 | 49 | ## How To Run the Server 50 | 51 | To run the server, use the following command: 52 | 53 | ```shell 54 | $ uvicorn app.main:app --host localhost --port 8000 --reload 55 | ``` 56 | 57 | This will spin up the server at `http://localhost:8000` with a local SQLite database `users.db`. 58 | 59 | ## API Endpoints 60 | 61 | ### Create User 62 | 63 | - `POST /api/users/`: Create a new user. 64 | 65 | To create a user, send a POST request to `http://localhost:8000/api/users` with the following JSON payload: 66 | 67 | ```json 68 | { 69 | "first_name": "John", 70 | "last_name": "Doe", 71 | "address": "123 Fake St", 72 | "activated": true 73 | } 74 | ``` 75 | 76 | As we use Pydantic models, the API will validate the request payload and return an error if the payload is invalid. 77 | 78 | ### Get Users 79 | 80 | - `GET /api/users/`: Get all users. 81 | 82 | To get all users, send a GET request to `http://localhost:8000/api/users`. 83 | 84 | ### Get User by ID 85 | 86 | - `GET /api/users/{userId}/`: Get a user by ID. 87 | 88 | To get a user by ID, send a GET request to `http://localhost:8000/api/users/{userId}`. 89 | 90 | If the user with the specified ID does not exist, the API will return a 404 Not Found response. The same logic is carried out for the Update and Delete endpoints. 91 | 92 | 93 | ### Update User 94 | 95 | - `PATCH /api/users/{userId}/`: Update a user by ID. 96 | 97 | To update a user by ID, send a PATCH request to `http://localhost:8000/api/users/{userId}` with the following JSON payload: 98 | 99 | ```json 100 | { 101 | "first_name": "Jane", 102 | "last_name": "Doe", 103 | "address": "321 Fake St", 104 | "activated": true 105 | } 106 | ``` 107 | 108 | ### Delete User 109 | 110 | - `DELETE /api/users/{userId}/`: Delete a user by ID. 111 | 112 | To delete a user by ID, send a DELETE request to `http://localhost:8000/api/users/{userId}`. 113 | 114 | ## How To Run the Unit Tests 115 | To run the Unit Tests, from the root of the repo run 116 | ```shell 117 | $ pytest 118 | ``` 119 | 120 | This will spin up a test database in SQLite `test_db.db`, run the tests and then tear down the database. 121 | 122 | You can use `pytest -v` for verbose output and `pytest -s` to disable output capture for better debugging. -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pytest-with-Eric/pytest-fastapi-crud-example/d47bb85f26c4dc55877563a6c79ecef2c8d50706/app/__init__.py -------------------------------------------------------------------------------- /app/database.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.ext.declarative import declarative_base 3 | from sqlalchemy.orm import sessionmaker 4 | 5 | SQLITE_DATABASE_URL = "sqlite:///./user.db" 6 | 7 | engine = create_engine( 8 | SQLITE_DATABASE_URL, echo=True, connect_args={"check_same_thread": False} 9 | ) 10 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 11 | 12 | Base = declarative_base() 13 | 14 | 15 | def get_db(): 16 | db = SessionLocal() 17 | try: 18 | yield db 19 | finally: 20 | db.close() 21 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from app import models, user 2 | from fastapi import FastAPI 3 | from fastapi.middleware.cors import CORSMiddleware 4 | from app.database import engine 5 | 6 | models.Base.metadata.create_all(bind=engine) 7 | 8 | app = FastAPI() 9 | 10 | origins = [ 11 | "http://localhost:3000", 12 | ] 13 | 14 | app.add_middleware( 15 | CORSMiddleware, 16 | allow_origins=origins, 17 | allow_credentials=True, 18 | allow_methods=["*"], 19 | allow_headers=["*"], 20 | ) 21 | 22 | 23 | app.include_router(user.router, tags=["Users"], prefix="/api/users") 24 | 25 | 26 | @app.get("/api/healthchecker") 27 | def root(): 28 | return {"message": "The API is LIVE!!"} 29 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | from app.database import Base 2 | from sqlalchemy import TIMESTAMP, Column, String, Boolean 3 | from sqlalchemy.sql import func 4 | from sqlalchemy_utils import UUIDType 5 | import uuid 6 | 7 | class User(Base): 8 | __tablename__ = "users" 9 | 10 | # Primary key and GUID type 11 | id = Column(UUIDType(binary=False), primary_key=True, default=uuid.uuid4) 12 | 13 | # String types with appropriate non-null constraints 14 | first_name = Column( 15 | String(255), nullable=False, index=True 16 | ) # Indexed for faster searches 17 | last_name = Column( 18 | String(255), nullable=False, index=True 19 | ) # Indexed for faster searches 20 | address = Column(String(255), nullable=True) 21 | 22 | # Boolean type with a default value 23 | activated = Column(Boolean, nullable=False, default=True) 24 | 25 | # Timestamps with timezone support 26 | createdAt = Column( 27 | TIMESTAMP(timezone=True), nullable=False, server_default=func.now() 28 | ) 29 | updatedAt = Column(TIMESTAMP(timezone=True), default=None, onupdate=func.now()) 30 | -------------------------------------------------------------------------------- /app/schemas.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from datetime import datetime 3 | from typing import List 4 | from pydantic import BaseModel, Field 5 | from uuid import UUID 6 | 7 | 8 | class UserBaseSchema(BaseModel): 9 | 10 | id: UUID | None = None 11 | first_name: str = Field( 12 | ..., description="The first name of the user", example="John" 13 | ) 14 | last_name: str = Field(..., description="The last name of the user", example="Doe") 15 | address: str | None = None 16 | activated: bool = False 17 | createdAt: datetime | None = None 18 | updatedAt: datetime | None = None 19 | 20 | class Config: 21 | from_attributes = True 22 | populate_by_name = True 23 | arbitrary_types_allowed = True 24 | 25 | 26 | class Status(Enum): 27 | Success = "Success" 28 | Failed = "Failed" 29 | 30 | 31 | class UserResponse(BaseModel): 32 | Status: Status 33 | User: UserBaseSchema 34 | 35 | 36 | class GetUserResponse(BaseModel): 37 | Status: Status 38 | User: UserBaseSchema 39 | 40 | 41 | class ListUserResponse(BaseModel): 42 | status: Status 43 | results: int 44 | users: List[UserBaseSchema] 45 | 46 | 47 | class DeleteUserResponse(BaseModel): 48 | Status: Status 49 | Message: str 50 | -------------------------------------------------------------------------------- /app/user.py: -------------------------------------------------------------------------------- 1 | import app.schemas as schemas 2 | import app.models as models 3 | from sqlalchemy.orm import Session 4 | from sqlalchemy.exc import IntegrityError 5 | from fastapi import Depends, HTTPException, status, APIRouter 6 | from app.database import get_db 7 | 8 | router = APIRouter() 9 | 10 | 11 | @router.post( 12 | "/", status_code=status.HTTP_201_CREATED, response_model=schemas.UserResponse 13 | ) 14 | def create_user(payload: schemas.UserBaseSchema, db: Session = Depends(get_db)): 15 | try: 16 | # Create a new user instance from the payload 17 | new_user = models.User(**payload.model_dump()) 18 | db.add(new_user) 19 | db.commit() 20 | db.refresh(new_user) 21 | 22 | except IntegrityError as e: 23 | db.rollback() 24 | # Log the error or handle it as needed 25 | raise HTTPException( 26 | status_code=status.HTTP_409_CONFLICT, 27 | detail="A user with the given details already exists.", 28 | ) from e 29 | except Exception as e: 30 | db.rollback() 31 | # Handle other types of database errors 32 | raise HTTPException( 33 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 34 | detail="An error occurred while creating the user.", 35 | ) from e 36 | 37 | # Convert the SQLAlchemy model instance to a Pydantic model 38 | user_schema = schemas.UserBaseSchema.from_orm(new_user) 39 | # Return the successful creation response 40 | return schemas.UserResponse(Status=schemas.Status.Success, User=user_schema) 41 | 42 | 43 | @router.get( 44 | "/{userId}", status_code=status.HTTP_200_OK, response_model=schemas.GetUserResponse 45 | ) 46 | def get_user(userId: str, db: Session = Depends(get_db)): 47 | user_query = db.query(models.User).filter(models.User.id == userId) 48 | db_user = user_query.first() 49 | 50 | if not db_user: 51 | raise HTTPException( 52 | status_code=status.HTTP_404_NOT_FOUND, 53 | detail=f"No User with this id: `{userId}` found", 54 | ) 55 | 56 | try: 57 | return schemas.GetUserResponse( 58 | Status=schemas.Status.Success, User=schemas.UserBaseSchema.model_validate(db_user) 59 | ) 60 | except Exception as e: 61 | raise HTTPException( 62 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 63 | detail="An unexpected error occurred while fetching the user.", 64 | ) from e 65 | 66 | 67 | @router.patch( 68 | "/{userId}", 69 | status_code=status.HTTP_202_ACCEPTED, 70 | response_model=schemas.UserResponse, 71 | ) 72 | def update_user( 73 | userId: str, payload: schemas.UserBaseSchema, db: Session = Depends(get_db) 74 | ): 75 | user_query = db.query(models.User).filter(models.User.id == userId) 76 | db_user = user_query.first() 77 | 78 | if not db_user: 79 | raise HTTPException( 80 | status_code=status.HTTP_404_NOT_FOUND, 81 | detail=f"No User with this id: `{userId}` found", 82 | ) 83 | 84 | try: 85 | update_data = payload.dict(exclude_unset=True) 86 | user_query.update(update_data, synchronize_session=False) 87 | db.commit() 88 | db.refresh(db_user) 89 | user_schema = schemas.UserBaseSchema.model_validate(db_user) 90 | return schemas.UserResponse(Status=schemas.Status.Success, User=user_schema) 91 | except IntegrityError as e: 92 | db.rollback() 93 | raise HTTPException( 94 | status_code=status.HTTP_409_CONFLICT, 95 | detail="A user with the given details already exists.", 96 | ) from e 97 | except Exception as e: 98 | db.rollback() 99 | raise HTTPException( 100 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 101 | detail="An error occurred while updating the user.", 102 | ) from e 103 | 104 | 105 | @router.delete( 106 | "/{userId}", 107 | status_code=status.HTTP_202_ACCEPTED, 108 | response_model=schemas.DeleteUserResponse, 109 | ) 110 | def delete_user(userId: str, db: Session = Depends(get_db)): 111 | try: 112 | user_query = db.query(models.User).filter(models.User.id == userId) 113 | user = user_query.first() 114 | if not user: 115 | raise HTTPException( 116 | status_code=status.HTTP_404_NOT_FOUND, 117 | detail=f"No User with this id: `{userId}` found", 118 | ) 119 | user_query.delete(synchronize_session=False) 120 | db.commit() 121 | return schemas.DeleteUserResponse( 122 | Status=schemas.Status.Success, Message="User deleted successfully" 123 | ) 124 | except Exception as e: 125 | db.rollback() 126 | raise HTTPException( 127 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 128 | detail="An error occurred while deleting the user.", 129 | ) from e 130 | 131 | 132 | @router.get( 133 | "/", status_code=status.HTTP_200_OK, response_model=schemas.ListUserResponse 134 | ) 135 | def get_users( 136 | db: Session = Depends(get_db), limit: int = 10, page: int = 1, search: str = "" 137 | ): 138 | skip = (page - 1) * limit 139 | 140 | users = ( 141 | db.query(models.User) 142 | .filter(models.User.first_name.contains(search)) 143 | .limit(limit) 144 | .offset(skip) 145 | .all() 146 | ) 147 | return schemas.ListUserResponse( 148 | status=schemas.Status.Success, results=len(users), users=users 149 | ) 150 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "annotated-types" 5 | version = "0.6.0" 6 | description = "Reusable constraint types to use with typing.Annotated" 7 | optional = false 8 | python-versions = ">=3.8" 9 | files = [ 10 | {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, 11 | {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, 12 | ] 13 | 14 | [[package]] 15 | name = "anyio" 16 | version = "4.3.0" 17 | description = "High level compatibility layer for multiple asynchronous event loop implementations" 18 | optional = false 19 | python-versions = ">=3.8" 20 | files = [ 21 | {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, 22 | {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, 23 | ] 24 | 25 | [package.dependencies] 26 | idna = ">=2.8" 27 | sniffio = ">=1.1" 28 | 29 | [package.extras] 30 | doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] 31 | test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] 32 | trio = ["trio (>=0.23)"] 33 | 34 | [[package]] 35 | name = "certifi" 36 | version = "2024.2.2" 37 | description = "Python package for providing Mozilla's CA Bundle." 38 | optional = false 39 | python-versions = ">=3.6" 40 | files = [ 41 | {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, 42 | {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, 43 | ] 44 | 45 | [[package]] 46 | name = "click" 47 | version = "8.1.7" 48 | description = "Composable command line interface toolkit" 49 | optional = false 50 | python-versions = ">=3.7" 51 | files = [ 52 | {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, 53 | {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, 54 | ] 55 | 56 | [package.dependencies] 57 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 58 | 59 | [[package]] 60 | name = "colorama" 61 | version = "0.4.6" 62 | description = "Cross-platform colored terminal text." 63 | optional = false 64 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 65 | files = [ 66 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 67 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 68 | ] 69 | 70 | [[package]] 71 | name = "fastapi" 72 | version = "0.110.3" 73 | description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" 74 | optional = false 75 | python-versions = ">=3.8" 76 | files = [ 77 | {file = "fastapi-0.110.3-py3-none-any.whl", hash = "sha256:fd7600612f755e4050beb74001310b5a7e1796d149c2ee363124abdfa0289d32"}, 78 | {file = "fastapi-0.110.3.tar.gz", hash = "sha256:555700b0159379e94fdbfc6bb66a0f1c43f4cf7060f25239af3d84b63a656626"}, 79 | ] 80 | 81 | [package.dependencies] 82 | 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" 83 | starlette = ">=0.37.2,<0.38.0" 84 | typing-extensions = ">=4.8.0" 85 | 86 | [package.extras] 87 | all = ["email_validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "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)"] 88 | 89 | [[package]] 90 | name = "greenlet" 91 | version = "3.0.3" 92 | description = "Lightweight in-process concurrent programming" 93 | optional = false 94 | python-versions = ">=3.7" 95 | files = [ 96 | {file = "greenlet-3.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a"}, 97 | {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881"}, 98 | {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b"}, 99 | {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a"}, 100 | {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83"}, 101 | {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405"}, 102 | {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f"}, 103 | {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb"}, 104 | {file = "greenlet-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9"}, 105 | {file = "greenlet-3.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61"}, 106 | {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559"}, 107 | {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e"}, 108 | {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33"}, 109 | {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379"}, 110 | {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22"}, 111 | {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3"}, 112 | {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d"}, 113 | {file = "greenlet-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728"}, 114 | {file = "greenlet-3.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be"}, 115 | {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e"}, 116 | {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676"}, 117 | {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc"}, 118 | {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230"}, 119 | {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf"}, 120 | {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305"}, 121 | {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6"}, 122 | {file = "greenlet-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2"}, 123 | {file = "greenlet-3.0.3-cp37-cp37m-macosx_11_0_universal2.whl", hash = "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274"}, 124 | {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0"}, 125 | {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f"}, 126 | {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414"}, 127 | {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c"}, 128 | {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41"}, 129 | {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7"}, 130 | {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6"}, 131 | {file = "greenlet-3.0.3-cp37-cp37m-win32.whl", hash = "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d"}, 132 | {file = "greenlet-3.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67"}, 133 | {file = "greenlet-3.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca"}, 134 | {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04"}, 135 | {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc"}, 136 | {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506"}, 137 | {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b"}, 138 | {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4"}, 139 | {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5"}, 140 | {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da"}, 141 | {file = "greenlet-3.0.3-cp38-cp38-win32.whl", hash = "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3"}, 142 | {file = "greenlet-3.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf"}, 143 | {file = "greenlet-3.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53"}, 144 | {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257"}, 145 | {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac"}, 146 | {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71"}, 147 | {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61"}, 148 | {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b"}, 149 | {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6"}, 150 | {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113"}, 151 | {file = "greenlet-3.0.3-cp39-cp39-win32.whl", hash = "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e"}, 152 | {file = "greenlet-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067"}, 153 | {file = "greenlet-3.0.3.tar.gz", hash = "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491"}, 154 | ] 155 | 156 | [package.extras] 157 | docs = ["Sphinx", "furo"] 158 | test = ["objgraph", "psutil"] 159 | 160 | [[package]] 161 | name = "h11" 162 | version = "0.14.0" 163 | description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 164 | optional = false 165 | python-versions = ">=3.7" 166 | files = [ 167 | {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, 168 | {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, 169 | ] 170 | 171 | [[package]] 172 | name = "httpcore" 173 | version = "1.0.5" 174 | description = "A minimal low-level HTTP client." 175 | optional = false 176 | python-versions = ">=3.8" 177 | files = [ 178 | {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, 179 | {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, 180 | ] 181 | 182 | [package.dependencies] 183 | certifi = "*" 184 | h11 = ">=0.13,<0.15" 185 | 186 | [package.extras] 187 | asyncio = ["anyio (>=4.0,<5.0)"] 188 | http2 = ["h2 (>=3,<5)"] 189 | socks = ["socksio (==1.*)"] 190 | trio = ["trio (>=0.22.0,<0.26.0)"] 191 | 192 | [[package]] 193 | name = "httpx" 194 | version = "0.27.0" 195 | description = "The next generation HTTP client." 196 | optional = false 197 | python-versions = ">=3.8" 198 | files = [ 199 | {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, 200 | {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, 201 | ] 202 | 203 | [package.dependencies] 204 | anyio = "*" 205 | certifi = "*" 206 | httpcore = "==1.*" 207 | idna = "*" 208 | sniffio = "*" 209 | 210 | [package.extras] 211 | brotli = ["brotli", "brotlicffi"] 212 | cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] 213 | http2 = ["h2 (>=3,<5)"] 214 | socks = ["socksio (==1.*)"] 215 | 216 | [[package]] 217 | name = "idna" 218 | version = "3.7" 219 | description = "Internationalized Domain Names in Applications (IDNA)" 220 | optional = false 221 | python-versions = ">=3.5" 222 | files = [ 223 | {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, 224 | {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, 225 | ] 226 | 227 | [[package]] 228 | name = "iniconfig" 229 | version = "2.0.0" 230 | description = "brain-dead simple config-ini parsing" 231 | optional = false 232 | python-versions = ">=3.7" 233 | files = [ 234 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 235 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 236 | ] 237 | 238 | [[package]] 239 | name = "packaging" 240 | version = "24.0" 241 | description = "Core utilities for Python packages" 242 | optional = false 243 | python-versions = ">=3.7" 244 | files = [ 245 | {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, 246 | {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, 247 | ] 248 | 249 | [[package]] 250 | name = "pluggy" 251 | version = "1.5.0" 252 | description = "plugin and hook calling mechanisms for python" 253 | optional = false 254 | python-versions = ">=3.8" 255 | files = [ 256 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 257 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 258 | ] 259 | 260 | [package.extras] 261 | dev = ["pre-commit", "tox"] 262 | testing = ["pytest", "pytest-benchmark"] 263 | 264 | [[package]] 265 | name = "pydantic" 266 | version = "2.7.1" 267 | description = "Data validation using Python type hints" 268 | optional = false 269 | python-versions = ">=3.8" 270 | files = [ 271 | {file = "pydantic-2.7.1-py3-none-any.whl", hash = "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5"}, 272 | {file = "pydantic-2.7.1.tar.gz", hash = "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc"}, 273 | ] 274 | 275 | [package.dependencies] 276 | annotated-types = ">=0.4.0" 277 | pydantic-core = "2.18.2" 278 | typing-extensions = ">=4.6.1" 279 | 280 | [package.extras] 281 | email = ["email-validator (>=2.0.0)"] 282 | 283 | [[package]] 284 | name = "pydantic-core" 285 | version = "2.18.2" 286 | description = "Core functionality for Pydantic validation and serialization" 287 | optional = false 288 | python-versions = ">=3.8" 289 | files = [ 290 | {file = "pydantic_core-2.18.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81"}, 291 | {file = "pydantic_core-2.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2"}, 292 | {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d"}, 293 | {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250"}, 294 | {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038"}, 295 | {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74"}, 296 | {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af"}, 297 | {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857"}, 298 | {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563"}, 299 | {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38"}, 300 | {file = "pydantic_core-2.18.2-cp310-none-win32.whl", hash = "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027"}, 301 | {file = "pydantic_core-2.18.2-cp310-none-win_amd64.whl", hash = "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543"}, 302 | {file = "pydantic_core-2.18.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3"}, 303 | {file = "pydantic_core-2.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4"}, 304 | {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90"}, 305 | {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd"}, 306 | {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150"}, 307 | {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413"}, 308 | {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6"}, 309 | {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c"}, 310 | {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0"}, 311 | {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664"}, 312 | {file = "pydantic_core-2.18.2-cp311-none-win32.whl", hash = "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e"}, 313 | {file = "pydantic_core-2.18.2-cp311-none-win_amd64.whl", hash = "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3"}, 314 | {file = "pydantic_core-2.18.2-cp311-none-win_arm64.whl", hash = "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d"}, 315 | {file = "pydantic_core-2.18.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242"}, 316 | {file = "pydantic_core-2.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043"}, 317 | {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182"}, 318 | {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f"}, 319 | {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3"}, 320 | {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f"}, 321 | {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72"}, 322 | {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c"}, 323 | {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241"}, 324 | {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3"}, 325 | {file = "pydantic_core-2.18.2-cp312-none-win32.whl", hash = "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038"}, 326 | {file = "pydantic_core-2.18.2-cp312-none-win_amd64.whl", hash = "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438"}, 327 | {file = "pydantic_core-2.18.2-cp312-none-win_arm64.whl", hash = "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec"}, 328 | {file = "pydantic_core-2.18.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439"}, 329 | {file = "pydantic_core-2.18.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347"}, 330 | {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91"}, 331 | {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb"}, 332 | {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd"}, 333 | {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b"}, 334 | {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70"}, 335 | {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b"}, 336 | {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761"}, 337 | {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788"}, 338 | {file = "pydantic_core-2.18.2-cp38-none-win32.whl", hash = "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350"}, 339 | {file = "pydantic_core-2.18.2-cp38-none-win_amd64.whl", hash = "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e"}, 340 | {file = "pydantic_core-2.18.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8"}, 341 | {file = "pydantic_core-2.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a"}, 342 | {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804"}, 343 | {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b"}, 344 | {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0"}, 345 | {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845"}, 346 | {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0"}, 347 | {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4"}, 348 | {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399"}, 349 | {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b"}, 350 | {file = "pydantic_core-2.18.2-cp39-none-win32.whl", hash = "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e"}, 351 | {file = "pydantic_core-2.18.2-cp39-none-win_amd64.whl", hash = "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641"}, 352 | {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75"}, 353 | {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d"}, 354 | {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9"}, 355 | {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7"}, 356 | {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb"}, 357 | {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a"}, 358 | {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b"}, 359 | {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3"}, 360 | {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c"}, 361 | {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce"}, 362 | {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400"}, 363 | {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349"}, 364 | {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c"}, 365 | {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592"}, 366 | {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae"}, 367 | {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374"}, 368 | {file = "pydantic_core-2.18.2.tar.gz", hash = "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e"}, 369 | ] 370 | 371 | [package.dependencies] 372 | typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" 373 | 374 | [[package]] 375 | name = "pytest" 376 | version = "8.2.0" 377 | description = "pytest: simple powerful testing with Python" 378 | optional = false 379 | python-versions = ">=3.8" 380 | files = [ 381 | {file = "pytest-8.2.0-py3-none-any.whl", hash = "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233"}, 382 | {file = "pytest-8.2.0.tar.gz", hash = "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f"}, 383 | ] 384 | 385 | [package.dependencies] 386 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 387 | iniconfig = "*" 388 | packaging = "*" 389 | pluggy = ">=1.5,<2.0" 390 | 391 | [package.extras] 392 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 393 | 394 | [[package]] 395 | name = "pytest-randomly" 396 | version = "3.15.0" 397 | description = "Pytest plugin to randomly order tests and control random.seed." 398 | optional = false 399 | python-versions = ">=3.8" 400 | files = [ 401 | {file = "pytest_randomly-3.15.0-py3-none-any.whl", hash = "sha256:0516f4344b29f4e9cdae8bce31c4aeebf59d0b9ef05927c33354ff3859eeeca6"}, 402 | {file = "pytest_randomly-3.15.0.tar.gz", hash = "sha256:b908529648667ba5e54723088edd6f82252f540cc340d748d1fa985539687047"}, 403 | ] 404 | 405 | [package.dependencies] 406 | pytest = "*" 407 | 408 | [[package]] 409 | name = "sniffio" 410 | version = "1.3.1" 411 | description = "Sniff out which async library your code is running under" 412 | optional = false 413 | python-versions = ">=3.7" 414 | files = [ 415 | {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, 416 | {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, 417 | ] 418 | 419 | [[package]] 420 | name = "sqlalchemy" 421 | version = "2.0.29" 422 | description = "Database Abstraction Library" 423 | optional = false 424 | python-versions = ">=3.7" 425 | files = [ 426 | {file = "SQLAlchemy-2.0.29-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4c142852ae192e9fe5aad5c350ea6befe9db14370b34047e1f0f7cf99e63c63b"}, 427 | {file = "SQLAlchemy-2.0.29-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:99a1e69d4e26f71e750e9ad6fdc8614fbddb67cfe2173a3628a2566034e223c7"}, 428 | {file = "SQLAlchemy-2.0.29-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ef3fbccb4058355053c51b82fd3501a6e13dd808c8d8cd2561e610c5456013c"}, 429 | {file = "SQLAlchemy-2.0.29-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d6753305936eddc8ed190e006b7bb33a8f50b9854823485eed3a886857ab8d1"}, 430 | {file = "SQLAlchemy-2.0.29-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0f3ca96af060a5250a8ad5a63699180bc780c2edf8abf96c58af175921df847a"}, 431 | {file = "SQLAlchemy-2.0.29-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c4520047006b1d3f0d89e0532978c0688219857eb2fee7c48052560ae76aca1e"}, 432 | {file = "SQLAlchemy-2.0.29-cp310-cp310-win32.whl", hash = "sha256:b2a0e3cf0caac2085ff172c3faacd1e00c376e6884b5bc4dd5b6b84623e29e4f"}, 433 | {file = "SQLAlchemy-2.0.29-cp310-cp310-win_amd64.whl", hash = "sha256:01d10638a37460616708062a40c7b55f73e4d35eaa146781c683e0fa7f6c43fb"}, 434 | {file = "SQLAlchemy-2.0.29-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:308ef9cb41d099099fffc9d35781638986870b29f744382904bf9c7dadd08513"}, 435 | {file = "SQLAlchemy-2.0.29-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:296195df68326a48385e7a96e877bc19aa210e485fa381c5246bc0234c36c78e"}, 436 | {file = "SQLAlchemy-2.0.29-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a13b917b4ffe5a0a31b83d051d60477819ddf18276852ea68037a144a506efb9"}, 437 | {file = "SQLAlchemy-2.0.29-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f6d971255d9ddbd3189e2e79d743ff4845c07f0633adfd1de3f63d930dbe673"}, 438 | {file = "SQLAlchemy-2.0.29-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:61405ea2d563407d316c63a7b5271ae5d274a2a9fbcd01b0aa5503635699fa1e"}, 439 | {file = "SQLAlchemy-2.0.29-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de7202ffe4d4a8c1e3cde1c03e01c1a3772c92858837e8f3879b497158e4cb44"}, 440 | {file = "SQLAlchemy-2.0.29-cp311-cp311-win32.whl", hash = "sha256:b5d7ed79df55a731749ce65ec20d666d82b185fa4898430b17cb90c892741520"}, 441 | {file = "SQLAlchemy-2.0.29-cp311-cp311-win_amd64.whl", hash = "sha256:205f5a2b39d7c380cbc3b5dcc8f2762fb5bcb716838e2d26ccbc54330775b003"}, 442 | {file = "SQLAlchemy-2.0.29-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d96710d834a6fb31e21381c6d7b76ec729bd08c75a25a5184b1089141356171f"}, 443 | {file = "SQLAlchemy-2.0.29-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:52de4736404e53c5c6a91ef2698c01e52333988ebdc218f14c833237a0804f1b"}, 444 | {file = "SQLAlchemy-2.0.29-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c7b02525ede2a164c5fa5014915ba3591730f2cc831f5be9ff3b7fd3e30958e"}, 445 | {file = "SQLAlchemy-2.0.29-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dfefdb3e54cd15f5d56fd5ae32f1da2d95d78319c1f6dfb9bcd0eb15d603d5d"}, 446 | {file = "SQLAlchemy-2.0.29-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a88913000da9205b13f6f195f0813b6ffd8a0c0c2bd58d499e00a30eb508870c"}, 447 | {file = "SQLAlchemy-2.0.29-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fecd5089c4be1bcc37c35e9aa678938d2888845a134dd016de457b942cf5a758"}, 448 | {file = "SQLAlchemy-2.0.29-cp312-cp312-win32.whl", hash = "sha256:8197d6f7a3d2b468861ebb4c9f998b9df9e358d6e1cf9c2a01061cb9b6cf4e41"}, 449 | {file = "SQLAlchemy-2.0.29-cp312-cp312-win_amd64.whl", hash = "sha256:9b19836ccca0d321e237560e475fd99c3d8655d03da80c845c4da20dda31b6e1"}, 450 | {file = "SQLAlchemy-2.0.29-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:87a1d53a5382cdbbf4b7619f107cc862c1b0a4feb29000922db72e5a66a5ffc0"}, 451 | {file = "SQLAlchemy-2.0.29-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a0732dffe32333211801b28339d2a0babc1971bc90a983e3035e7b0d6f06b93"}, 452 | {file = "SQLAlchemy-2.0.29-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90453597a753322d6aa770c5935887ab1fc49cc4c4fdd436901308383d698b4b"}, 453 | {file = "SQLAlchemy-2.0.29-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ea311d4ee9a8fa67f139c088ae9f905fcf0277d6cd75c310a21a88bf85e130f5"}, 454 | {file = "SQLAlchemy-2.0.29-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5f20cb0a63a3e0ec4e169aa8890e32b949c8145983afa13a708bc4b0a1f30e03"}, 455 | {file = "SQLAlchemy-2.0.29-cp37-cp37m-win32.whl", hash = "sha256:e5bbe55e8552019c6463709b39634a5fc55e080d0827e2a3a11e18eb73f5cdbd"}, 456 | {file = "SQLAlchemy-2.0.29-cp37-cp37m-win_amd64.whl", hash = "sha256:c2f9c762a2735600654c654bf48dad388b888f8ce387b095806480e6e4ff6907"}, 457 | {file = "SQLAlchemy-2.0.29-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7e614d7a25a43a9f54fcce4675c12761b248547f3d41b195e8010ca7297c369c"}, 458 | {file = "SQLAlchemy-2.0.29-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:471fcb39c6adf37f820350c28aac4a7df9d3940c6548b624a642852e727ea586"}, 459 | {file = "SQLAlchemy-2.0.29-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:988569c8732f54ad3234cf9c561364221a9e943b78dc7a4aaf35ccc2265f1930"}, 460 | {file = "SQLAlchemy-2.0.29-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dddaae9b81c88083e6437de95c41e86823d150f4ee94bf24e158a4526cbead01"}, 461 | {file = "SQLAlchemy-2.0.29-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:334184d1ab8f4c87f9652b048af3f7abea1c809dfe526fb0435348a6fef3d380"}, 462 | {file = "SQLAlchemy-2.0.29-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:38b624e5cf02a69b113c8047cf7f66b5dfe4a2ca07ff8b8716da4f1b3ae81567"}, 463 | {file = "SQLAlchemy-2.0.29-cp38-cp38-win32.whl", hash = "sha256:bab41acf151cd68bc2b466deae5deeb9e8ae9c50ad113444151ad965d5bf685b"}, 464 | {file = "SQLAlchemy-2.0.29-cp38-cp38-win_amd64.whl", hash = "sha256:52c8011088305476691b8750c60e03b87910a123cfd9ad48576d6414b6ec2a1d"}, 465 | {file = "SQLAlchemy-2.0.29-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3071ad498896907a5ef756206b9dc750f8e57352113c19272bdfdc429c7bd7de"}, 466 | {file = "SQLAlchemy-2.0.29-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dba622396a3170974f81bad49aacebd243455ec3cc70615aeaef9e9613b5bca5"}, 467 | {file = "SQLAlchemy-2.0.29-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b184e3de58009cc0bf32e20f137f1ec75a32470f5fede06c58f6c355ed42a72"}, 468 | {file = "SQLAlchemy-2.0.29-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c37f1050feb91f3d6c32f864d8e114ff5545a4a7afe56778d76a9aec62638ba"}, 469 | {file = "SQLAlchemy-2.0.29-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bda7ce59b06d0f09afe22c56714c65c957b1068dee3d5e74d743edec7daba552"}, 470 | {file = "SQLAlchemy-2.0.29-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:25664e18bef6dc45015b08f99c63952a53a0a61f61f2e48a9e70cec27e55f699"}, 471 | {file = "SQLAlchemy-2.0.29-cp39-cp39-win32.whl", hash = "sha256:77d29cb6c34b14af8a484e831ab530c0f7188f8efed1c6a833a2c674bf3c26ec"}, 472 | {file = "SQLAlchemy-2.0.29-cp39-cp39-win_amd64.whl", hash = "sha256:04c487305ab035a9548f573763915189fc0fe0824d9ba28433196f8436f1449c"}, 473 | {file = "SQLAlchemy-2.0.29-py3-none-any.whl", hash = "sha256:dc4ee2d4ee43251905f88637d5281a8d52e916a021384ec10758826f5cbae305"}, 474 | {file = "SQLAlchemy-2.0.29.tar.gz", hash = "sha256:bd9566b8e58cabd700bc367b60e90d9349cd16f0984973f98a9a09f9c64e86f0"}, 475 | ] 476 | 477 | [package.dependencies] 478 | greenlet = {version = "!=0.4.17", markers = "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\""} 479 | typing-extensions = ">=4.6.0" 480 | 481 | [package.extras] 482 | aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"] 483 | aioodbc = ["aioodbc", "greenlet (!=0.4.17)"] 484 | aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"] 485 | asyncio = ["greenlet (!=0.4.17)"] 486 | asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] 487 | mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"] 488 | mssql = ["pyodbc"] 489 | mssql-pymssql = ["pymssql"] 490 | mssql-pyodbc = ["pyodbc"] 491 | mypy = ["mypy (>=0.910)"] 492 | mysql = ["mysqlclient (>=1.4.0)"] 493 | mysql-connector = ["mysql-connector-python"] 494 | oracle = ["cx_oracle (>=8)"] 495 | oracle-oracledb = ["oracledb (>=1.0.1)"] 496 | postgresql = ["psycopg2 (>=2.7)"] 497 | postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] 498 | postgresql-pg8000 = ["pg8000 (>=1.29.1)"] 499 | postgresql-psycopg = ["psycopg (>=3.0.7)"] 500 | postgresql-psycopg2binary = ["psycopg2-binary"] 501 | postgresql-psycopg2cffi = ["psycopg2cffi"] 502 | postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] 503 | pymysql = ["pymysql"] 504 | sqlcipher = ["sqlcipher3_binary"] 505 | 506 | [[package]] 507 | name = "sqlalchemy-utils" 508 | version = "0.41.2" 509 | description = "Various utility functions for SQLAlchemy." 510 | optional = false 511 | python-versions = ">=3.7" 512 | files = [ 513 | {file = "SQLAlchemy-Utils-0.41.2.tar.gz", hash = "sha256:bc599c8c3b3319e53ce6c5c3c471120bd325d0071fb6f38a10e924e3d07b9990"}, 514 | {file = "SQLAlchemy_Utils-0.41.2-py3-none-any.whl", hash = "sha256:85cf3842da2bf060760f955f8467b87983fb2e30f1764fd0e24a48307dc8ec6e"}, 515 | ] 516 | 517 | [package.dependencies] 518 | SQLAlchemy = ">=1.3" 519 | 520 | [package.extras] 521 | arrow = ["arrow (>=0.3.4)"] 522 | babel = ["Babel (>=1.3)"] 523 | color = ["colour (>=0.0.4)"] 524 | encrypted = ["cryptography (>=0.6)"] 525 | intervals = ["intervals (>=0.7.1)"] 526 | password = ["passlib (>=1.6,<2.0)"] 527 | pendulum = ["pendulum (>=2.0.5)"] 528 | phone = ["phonenumbers (>=5.9.2)"] 529 | test = ["Jinja2 (>=2.3)", "Pygments (>=1.2)", "backports.zoneinfo", "docutils (>=0.10)", "flake8 (>=2.4.0)", "flexmock (>=0.9.7)", "isort (>=4.2.2)", "pg8000 (>=1.12.4)", "psycopg (>=3.1.8)", "psycopg2 (>=2.5.1)", "psycopg2cffi (>=2.8.1)", "pymysql", "pyodbc", "pytest (==7.4.4)", "python-dateutil (>=2.6)", "pytz (>=2014.2)"] 530 | test-all = ["Babel (>=1.3)", "Jinja2 (>=2.3)", "Pygments (>=1.2)", "arrow (>=0.3.4)", "backports.zoneinfo", "colour (>=0.0.4)", "cryptography (>=0.6)", "docutils (>=0.10)", "flake8 (>=2.4.0)", "flexmock (>=0.9.7)", "furl (>=0.4.1)", "intervals (>=0.7.1)", "isort (>=4.2.2)", "passlib (>=1.6,<2.0)", "pendulum (>=2.0.5)", "pg8000 (>=1.12.4)", "phonenumbers (>=5.9.2)", "psycopg (>=3.1.8)", "psycopg2 (>=2.5.1)", "psycopg2cffi (>=2.8.1)", "pymysql", "pyodbc", "pytest (==7.4.4)", "python-dateutil", "python-dateutil (>=2.6)", "pytz (>=2014.2)"] 531 | timezone = ["python-dateutil"] 532 | url = ["furl (>=0.4.1)"] 533 | 534 | [[package]] 535 | name = "starlette" 536 | version = "0.37.2" 537 | description = "The little ASGI library that shines." 538 | optional = false 539 | python-versions = ">=3.8" 540 | files = [ 541 | {file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"}, 542 | {file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"}, 543 | ] 544 | 545 | [package.dependencies] 546 | anyio = ">=3.4.0,<5" 547 | 548 | [package.extras] 549 | full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] 550 | 551 | [[package]] 552 | name = "typing-extensions" 553 | version = "4.11.0" 554 | description = "Backported and Experimental Type Hints for Python 3.8+" 555 | optional = false 556 | python-versions = ">=3.8" 557 | files = [ 558 | {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, 559 | {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, 560 | ] 561 | 562 | [[package]] 563 | name = "uvicorn" 564 | version = "0.29.0" 565 | description = "The lightning-fast ASGI server." 566 | optional = false 567 | python-versions = ">=3.8" 568 | files = [ 569 | {file = "uvicorn-0.29.0-py3-none-any.whl", hash = "sha256:2c2aac7ff4f4365c206fd773a39bf4ebd1047c238f8b8268ad996829323473de"}, 570 | {file = "uvicorn-0.29.0.tar.gz", hash = "sha256:6a69214c0b6a087462412670b3ef21224fa48cae0e452b5883e8e8bdfdd11dd0"}, 571 | ] 572 | 573 | [package.dependencies] 574 | click = ">=7.0" 575 | h11 = ">=0.8" 576 | 577 | [package.extras] 578 | standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] 579 | 580 | [metadata] 581 | lock-version = "2.0" 582 | python-versions = "^3.12" 583 | content-hash = "b2445091545390e67e0b6a1c13ce799b10f61b7a5652529e49f55007a51135e7" 584 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "user-service-example" 3 | version = "0.1.0" 4 | description = "A simple user service example" 5 | authors = ["ericsalesdeandrade "] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.12" 10 | fastapi = "0.110.3" 11 | uvicorn = "^0.29.0" 12 | pydantic = "^2.7.1" 13 | httpx = "^0.27.0" 14 | sqlalchemy-utils = "^0.41.2" 15 | 16 | 17 | [tool.poetry.group.test.dependencies] 18 | pytest = "^8.2.0" 19 | pytest-randomly = "^3.15.0" 20 | 21 | [build-system] 22 | requires = ["poetry-core"] 23 | build-backend = "poetry.core.masonry.api" 24 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | ; log_cli=true 3 | ; log_level=INFO 4 | addopts = -p no:warnings -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | 2 | fastapi~=0.110.3 3 | httpx~=0.27.0 4 | pydantic~=2.7.1 5 | sqlalchemy-utils~=0.41.2 6 | sqlalchemy~=2.0.29 7 | starlette~=0.37.2 8 | uvicorn~=0.29.0 9 | pytest~=8.2.0 10 | pytest-randomly~=3.15.0 11 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pytest-with-Eric/pytest-fastapi-crud-example/d47bb85f26c4dc55877563a6c79ecef2c8d50706/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import uuid 3 | from sqlalchemy import create_engine 4 | from sqlalchemy.orm import sessionmaker 5 | from sqlalchemy.pool import StaticPool 6 | from fastapi.testclient import TestClient 7 | from app.main import app 8 | from app.database import Base, get_db 9 | 10 | # SQLite database URL for testing 11 | SQLITE_DATABASE_URL = "sqlite:///./test_db.db" 12 | 13 | # Create a SQLAlchemy engine 14 | engine = create_engine( 15 | SQLITE_DATABASE_URL, 16 | connect_args={"check_same_thread": False}, 17 | poolclass=StaticPool, 18 | ) 19 | 20 | # Create a sessionmaker to manage sessions 21 | TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 22 | 23 | # Create tables in the database 24 | Base.metadata.create_all(bind=engine) 25 | 26 | 27 | @pytest.fixture(scope="function") 28 | def db_session(): 29 | """Create a new database session with a rollback at the end of the test.""" 30 | connection = engine.connect() 31 | transaction = connection.begin() 32 | session = TestingSessionLocal(bind=connection) 33 | yield session 34 | session.close() 35 | transaction.rollback() 36 | connection.close() 37 | 38 | 39 | @pytest.fixture(scope="function") 40 | def test_client(db_session): 41 | """Create a test client that uses the override_get_db fixture to return a session.""" 42 | 43 | def override_get_db(): 44 | try: 45 | yield db_session 46 | finally: 47 | db_session.close() 48 | 49 | app.dependency_overrides[get_db] = override_get_db 50 | with TestClient(app) as test_client: 51 | yield test_client 52 | 53 | 54 | # Fixture to generate a random user id 55 | @pytest.fixture() 56 | def user_id() -> uuid.UUID: 57 | """Generate a random user id.""" 58 | return str(uuid.uuid4()) 59 | 60 | 61 | # Fixture to generate a user payload 62 | @pytest.fixture() 63 | def user_payload(user_id): 64 | """Generate a user payload.""" 65 | return { 66 | "id": user_id, 67 | "first_name": "John", 68 | "last_name": "Doe", 69 | "address": "123 Farmville", 70 | } 71 | 72 | 73 | @pytest.fixture() 74 | def user_payload_updated(user_id): 75 | """Generate an updated user payload.""" 76 | return { 77 | "first_name": "Jane", 78 | "last_name": "Doe", 79 | "address": "321 Farmville", 80 | "activated": True, 81 | } 82 | -------------------------------------------------------------------------------- /tests/test_crud_api.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def test_root(test_client): 5 | response = test_client.get("/api/healthchecker") 6 | assert response.status_code == 200 7 | assert response.json() == {"message": "The API is LIVE!!"} 8 | 9 | 10 | def test_create_get_user(test_client, user_payload): 11 | response = test_client.post("/api/users/", json=user_payload) 12 | response_json = response.json() 13 | assert response.status_code == 201 14 | 15 | # Get the created user 16 | response = test_client.get(f"/api/users/{user_payload['id']}") 17 | assert response.status_code == 200 18 | response_json = response.json() 19 | assert response_json["Status"] == "Success" 20 | assert response_json["User"]["id"] == user_payload["id"] 21 | assert response_json["User"]["address"] == "123 Farmville" 22 | assert response_json["User"]["first_name"] == "John" 23 | assert response_json["User"]["last_name"] == "Doe" 24 | 25 | 26 | def test_create_update_user(test_client, user_payload, user_payload_updated): 27 | response = test_client.post("/api/users/", json=user_payload) 28 | response_json = response.json() 29 | assert response.status_code == 201 30 | 31 | # Update the created user 32 | time.sleep( 33 | 1 34 | ) # Sleep for 1 second to ensure updatedAt is different (datetime precision is low in SQLite) 35 | response = test_client.patch( 36 | f"/api/users/{user_payload['id']}", json=user_payload_updated 37 | ) 38 | response_json = response.json() 39 | assert response.status_code == 202 40 | assert response_json["Status"] == "Success" 41 | assert response_json["User"]["id"] == user_payload["id"] 42 | assert response_json["User"]["address"] == "321 Farmville" 43 | assert response_json["User"]["first_name"] == "Jane" 44 | assert response_json["User"]["last_name"] == "Doe" 45 | assert response_json["User"]["activated"] is True 46 | assert ( 47 | response_json["User"]["updatedAt"] is not None 48 | and response_json["User"]["updatedAt"] > response_json["User"]["createdAt"] 49 | ) 50 | 51 | 52 | def test_create_delete_user(test_client, user_payload): 53 | response = test_client.post("/api/users/", json=user_payload) 54 | response_json = response.json() 55 | assert response.status_code == 201 56 | 57 | # Delete the created user 58 | response = test_client.delete(f"/api/users/{user_payload['id']}") 59 | response_json = response.json() 60 | assert response.status_code == 202 61 | assert response_json["Status"] == "Success" 62 | assert response_json["Message"] == "User deleted successfully" 63 | 64 | # Get the deleted user 65 | response = test_client.get(f"/api/users/{user_payload['id']}") 66 | assert response.status_code == 404 67 | response_json = response.json() 68 | assert response_json["detail"] == f"No User with this id: `{user_payload['id']}` found" 69 | 70 | 71 | def test_get_user_not_found(test_client, user_id): 72 | response = test_client.get(f"/api/users/{user_id}") 73 | assert response.status_code == 404 74 | response_json = response.json() 75 | assert response_json["detail"] == f"No User with this id: `{user_id}` found" 76 | 77 | 78 | def test_create_user_wrong_payload(test_client): 79 | response = test_client.post("/api/users/", json={}) 80 | assert response.status_code == 422 81 | 82 | 83 | def test_update_user_wrong_payload(test_client, user_id, user_payload_updated): 84 | user_payload_updated["first_name"] = ( 85 | True # first_name should be a string not a boolean 86 | ) 87 | response = test_client.patch(f"/api/users/{user_id}", json=user_payload_updated) 88 | assert response.status_code == 422 89 | response_json = response.json() 90 | assert response_json == { 91 | "detail": [ 92 | { 93 | "type": "string_type", 94 | "loc": ["body", "first_name"], 95 | "msg": "Input should be a valid string", 96 | "input": True, 97 | } 98 | ] 99 | } 100 | 101 | 102 | def test_update_user_doesnt_exist(test_client, user_id, user_payload_updated): 103 | response = test_client.patch(f"/api/users/{user_id}", json=user_payload_updated) 104 | assert response.status_code == 404 105 | response_json = response.json() 106 | assert response_json["detail"] == f"No User with this id: `{user_id}` found" 107 | --------------------------------------------------------------------------------