diff --git a/.env b/.env new file mode 100644 index 0000000000000000000000000000000000000000..6cd5e00eadf77556dd41549765a3bdea090bec4b --- /dev/null +++ b/.env @@ -0,0 +1,7 @@ +DATABASE_URL=postgresql+asyncpg://neondb_owner:npg_PrJpZot1wWj7@ep-weathered-unit-ad0z27gz-pooler.c-2.us-east-1.aws.neon.tech/neondb?sslmode=require +DB_ECHO=true +BETTER_AUTH_SECRET=abfe95adc6a3d85f1d8533a0fbf151b18240d817b471dda39a925555d886549c32c667dbeb184b9e9c73da3227c0dae5f83a +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +DEBUG=true +GEMINI_API_KEY=AIzaSyCIBHuTxHwQQyUtJ_Zbokuu-Qv0mykCUUc diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index a6344aac8c09253b3b630fb776ae94478aa0275b..0000000000000000000000000000000000000000 --- a/.gitattributes +++ /dev/null @@ -1,35 +0,0 @@ -*.7z filter=lfs diff=lfs merge=lfs -text -*.arrow filter=lfs diff=lfs merge=lfs -text -*.bin filter=lfs diff=lfs merge=lfs -text -*.bz2 filter=lfs diff=lfs merge=lfs -text -*.ckpt filter=lfs diff=lfs merge=lfs -text -*.ftz filter=lfs diff=lfs merge=lfs -text -*.gz filter=lfs diff=lfs merge=lfs -text -*.h5 filter=lfs diff=lfs merge=lfs -text -*.joblib filter=lfs diff=lfs merge=lfs -text -*.lfs.* filter=lfs diff=lfs merge=lfs -text -*.mlmodel filter=lfs diff=lfs merge=lfs -text -*.model filter=lfs diff=lfs merge=lfs -text -*.msgpack filter=lfs diff=lfs merge=lfs -text -*.npy filter=lfs diff=lfs merge=lfs -text -*.npz filter=lfs diff=lfs merge=lfs -text -*.onnx filter=lfs diff=lfs merge=lfs -text -*.ot filter=lfs diff=lfs merge=lfs -text -*.parquet filter=lfs diff=lfs merge=lfs -text -*.pb filter=lfs diff=lfs merge=lfs -text -*.pickle filter=lfs diff=lfs merge=lfs -text -*.pkl filter=lfs diff=lfs merge=lfs -text -*.pt filter=lfs diff=lfs merge=lfs -text -*.pth filter=lfs diff=lfs merge=lfs -text -*.rar filter=lfs diff=lfs merge=lfs -text -*.safetensors filter=lfs diff=lfs merge=lfs -text -saved_model/**/* filter=lfs diff=lfs merge=lfs -text -*.tar.* filter=lfs diff=lfs merge=lfs -text -*.tar filter=lfs diff=lfs merge=lfs -text -*.tflite filter=lfs diff=lfs merge=lfs -text -*.tgz filter=lfs diff=lfs merge=lfs -text -*.wasm filter=lfs diff=lfs merge=lfs -text -*.xz filter=lfs diff=lfs merge=lfs -text -*.zip filter=lfs diff=lfs merge=lfs -text -*.zst filter=lfs diff=lfs merge=lfs -text -*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000000000000000000000000000000000..e4fba2183587225f216eeada4c78dfab6b2e65f5 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..4af18cf972d5e077f58b961eb0f7717364f4ed38 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.10 + +RUN apt-get update && apt-get install -y libgl1 libglib2.0-0 + +# Create user but stay root during install +RUN useradd -m -u 1000 user + +WORKDIR / + +# Install dependencies as root +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade -r requirements.txt + +# Copy app AFTER installing dependencies +COPY . / + +# Switch to non-root user for safety +USER user + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/README.md b/README.md index 3a8ae4b7c6f57321f767f60bdf64f0637672cdeb..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 --- a/README.md +++ b/README.md @@ -1,11 +0,0 @@ ---- -title: Todo App -emoji: ⚡ -colorFrom: blue -colorTo: yellow -sdk: docker -pinned: false -license: mit ---- - -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..49c64f1b604bcb8f7a61088b625cb1da9e24cd74 Binary files /dev/null and b/__pycache__/main.cpython-313.pyc differ diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/__pycache__/__init__.cpython-313.pyc b/api/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..40781eb0aef129f48d208b73f45df2770e2af1cd Binary files /dev/null and b/api/__pycache__/__init__.cpython-313.pyc differ diff --git a/api/v1/__init__.py b/api/v1/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/v1/__pycache__/__init__.cpython-313.pyc b/api/v1/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..13841fdfe38bd8955829f7b3b95436de2424b496 Binary files /dev/null and b/api/v1/__pycache__/__init__.cpython-313.pyc differ diff --git a/api/v1/routes/__init__.py b/api/v1/routes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/v1/routes/__pycache__/__init__.cpython-313.pyc b/api/v1/routes/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff29ff2936a02a9b182bff1341b1489f97c339e2 Binary files /dev/null and b/api/v1/routes/__pycache__/__init__.cpython-313.pyc differ diff --git a/api/v1/routes/__pycache__/auth.cpython-313.pyc b/api/v1/routes/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f80933f16de62ef813fbda088efb2fed0a8012ec Binary files /dev/null and b/api/v1/routes/__pycache__/auth.cpython-313.pyc differ diff --git a/api/v1/routes/__pycache__/tasks.cpython-313.pyc b/api/v1/routes/__pycache__/tasks.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9980593d6bd79664aacba23874f5745bbfd8c287 Binary files /dev/null and b/api/v1/routes/__pycache__/tasks.cpython-313.pyc differ diff --git a/api/v1/routes/auth.py b/api/v1/routes/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..3760761d98062fc6eb914d4a226ca04d6885cd47 --- /dev/null +++ b/api/v1/routes/auth.py @@ -0,0 +1,235 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel.ext.asyncio.session import AsyncSession +from pydantic import BaseModel + +from database.session import get_session_dep +from models.user import UserCreate, User +from services.user_service import UserService +from auth.jwt_handler import create_access_token, create_refresh_token, verify_token +from utils.logging import get_logger +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import logging + +router = APIRouter() +logger = get_logger(__name__) + +# Models for auth endpoints +class UserLogin(BaseModel): + email: str + password: str # In a real app, this would be hashed, but for this demo we'll keep it simple + +class UserRegister(BaseModel): + email: str + password: str # In a real app, this would be hashed + name: str + +class AuthResponse(BaseModel): + user: dict + token: str + refresh_token: str = None + +# Initialize security for token verification (for logout) +security = HTTPBearer() + +@router.post("/auth/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED) +async def register_user( + user_data: UserRegister, + session: AsyncSession = Depends(get_session_dep) +): + """ + Register a new user and return JWT token. + + Args: + user_data: User registration data (email, password, name) + session: Database session + + Returns: + AuthResponse with user data and JWT token + """ + try: + # Create user data object for the service + user_create_data = UserCreate( + email=user_data.email, + name=user_data.name + ) + + # Create user in database + created_user = await UserService.create_user(session, user_create_data) + + # Create JWT tokens + token_data = {"sub": str(created_user.id), "email": created_user.email} + token = create_access_token(data=token_data) + refresh_token = create_refresh_token(data=token_data) + + logger.info(f"Successfully registered user {created_user.id} with email {created_user.email}") + + return AuthResponse( + user=created_user.model_dump(), + token=token, + refresh_token=refresh_token + ) + + except HTTPException: + # Re-raise HTTP exceptions (like 400 for duplicate email) + raise + except Exception as e: + logger.error(f"Error registering user with email {user_data.email}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error registering user" + ) + + +@router.post("/auth/login", response_model=AuthResponse) +async def login_user( + user_data: UserLogin, + session: AsyncSession = Depends(get_session_dep) +): + """ + Login a user and return JWT token. + + Args: + user_data: User login data (email, password) + session: Database session + + Returns: + AuthResponse with user data and JWT token + """ + try: + # Find user by email + user = await UserService.get_user_by_email(session, user_data.email) + + if not user: + logger.warning(f"Login attempt with non-existent email: {user_data.email}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password" + ) + + # In a real app, we would verify the password here. + # For this implementation, we'll just proceed with login. + + # Create JWT tokens + token_data = {"sub": str(user.id), "email": user.email} + token = create_access_token(data=token_data) + refresh_token = create_refresh_token(data=token_data) + + logger.info(f"Successfully logged in user {user.id} with email {user.email}") + + # Convert user to dict for response + user_dict = { + "id": user.id, + "email": user.email, + "name": user.name, + "created_at": user.created_at + } + + return AuthResponse( + user=user_dict, + token=token, + refresh_token=refresh_token + ) + + except HTTPException: + # Re-raise HTTP exceptions (like 401 for invalid credentials) + raise + except Exception as e: + logger.error(f"Error logging in user with email {user_data.email}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error during login" + ) + + +@router.post("/auth/logout") +async def logout_user( + token: HTTPAuthorizationCredentials = Depends(security) +): + """ + Logout endpoint. + In a real application, this would add the token to a blacklist/jti store. + For this implementation, we'll just return a success message. + """ + try: + # In a real app, you would add the token to a blacklist or token revocation store + # For this demo, we'll just return a success message + logger.info(f"User logged out successfully") + return {"message": "Successfully logged out"} + except Exception as e: + logger.error(f"Error during logout: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error during logout" + ) + + +class RefreshTokenRequest(BaseModel): + refresh_token: str + + +@router.post("/auth/refresh", response_model=AuthResponse) +async def refresh_token( + refresh_request: RefreshTokenRequest +): + """ + Refresh access token using a valid refresh token. + + Args: + refresh_request: Contains the refresh token to use for generating a new access token + + Returns: + AuthResponse with new access token and refresh token + """ + try: + # Verify the refresh token + payload = verify_token(refresh_request.refresh_token) + + # Check if this is a refresh token (not an access token) + token_type = payload.get("type") + if token_type != "refresh": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token type for refresh", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Extract user data from the refresh token + user_id = payload.get("sub") + user_email = payload.get("email") + + if not user_id or not user_email: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Create new access and refresh tokens + token_data = {"sub": user_id, "email": user_email} + new_access_token = create_access_token(data=token_data) + new_refresh_token = create_refresh_token(data=token_data) + + logger.info(f"Successfully refreshed token for user {user_id}") + + # Return new tokens with minimal user data (we don't have full user details here) + user_dict = { + "id": user_id, + "email": user_email + } + + return AuthResponse( + user=user_dict, + token=new_access_token, + refresh_token=new_refresh_token + ) + + except HTTPException: + # Re-raise HTTP exceptions + raise + except Exception as e: + logger.error(f"Error refreshing token: {str(e)}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not refresh token", + headers={"WWW-Authenticate": "Bearer"}, + ) \ No newline at end of file diff --git a/api/v1/routes/tasks.py b/api/v1/routes/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..ffa5acb6f464a67d0cde86a7d610db1e00bb24cd --- /dev/null +++ b/api/v1/routes/tasks.py @@ -0,0 +1,328 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlmodel.ext.asyncio.session import AsyncSession +from typing import List +from database.session import get_session_dep +from models.task import TaskRead, TaskCreate, TaskUpdate, TaskComplete +from services.task_service import TaskService +from middleware.auth_middleware import validate_user_id_from_token +from auth.jwt_handler import get_user_id_from_token +from utils.logging import get_logger +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +import logging + +router = APIRouter() +logger = get_logger(__name__) + +# Initialize security for token extraction +security = HTTPBearer() + + +@router.get("/tasks", response_model=List[TaskRead]) +async def get_tasks( + request: Request, + user_id: int, + token: HTTPAuthorizationCredentials = Depends(security), + session: AsyncSession = Depends(get_session_dep) +): + """ + Retrieve all tasks for the specified user. + + Args: + request: FastAPI request object + user_id: The ID of the user whose tasks to retrieve + token: JWT token for authentication + session: Database session + + Returns: + List of TaskRead objects + + Raises: + HTTPException: If authentication fails or user_id validation fails + """ + try: + # Extract and validate token + token_user_id = get_user_id_from_token(token.credentials) + + # Validate that token user_id matches URL user_id + validate_user_id_from_token( + request=request, + token_user_id=token_user_id, + url_user_id=user_id + ) + + # Get tasks for the user + tasks = await TaskService.get_tasks_by_user_id(session, user_id) + + logger.info(f"Successfully retrieved {len(tasks)} tasks for user {user_id}") + return tasks + + except HTTPException: + # Re-raise HTTP exceptions (like 401, 403, 404) + raise + except Exception as e: + logger.error(f"Error retrieving tasks for user {user_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error retrieving tasks" + ) + + +@router.post("/tasks", response_model=TaskRead, status_code=status.HTTP_201_CREATED) +async def create_task( + request: Request, + user_id: int, + task_data: TaskCreate, + token: HTTPAuthorizationCredentials = Depends(security), + session: AsyncSession = Depends(get_session_dep) +): + """ + Create a new task for the specified user. + + Args: + request: FastAPI request object + user_id: The ID of the user creating the task + task_data: Task creation data + token: JWT token for authentication + session: Database session + + Returns: + Created TaskRead object + + Raises: + HTTPException: If authentication fails, user_id validation fails, or task creation fails + """ + try: + # Extract and validate token + token_user_id = get_user_id_from_token(token.credentials) + + # Validate that token user_id matches URL user_id + validate_user_id_from_token( + request=request, + token_user_id=token_user_id, + url_user_id=user_id + ) + + # Create the task + created_task = await TaskService.create_task(session, user_id, task_data) + + logger.info(f"Successfully created task {created_task.id} for user {user_id}") + return created_task + + except HTTPException: + # Re-raise HTTP exceptions (like 401, 403, 400) + raise + except Exception as e: + logger.error(f"Error creating task for user {user_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error creating task" + ) + + +@router.get("/tasks/{task_id}", response_model=TaskRead) +async def get_task( + request: Request, + user_id: int, + task_id: int, + token: HTTPAuthorizationCredentials = Depends(security), + session: AsyncSession = Depends(get_session_dep) +): + """ + Retrieve a specific task by ID for the specified user. + + Args: + request: FastAPI request object + user_id: The ID of the user + task_id: The ID of the task to retrieve + token: JWT token for authentication + session: Database session + + Returns: + TaskRead object + + Raises: + HTTPException: If authentication fails, user_id validation fails, or task not found + """ + try: + # Extract and validate token + token_user_id = get_user_id_from_token(token.credentials) + + # Validate that token user_id matches URL user_id + validate_user_id_from_token( + request=request, + token_user_id=token_user_id, + url_user_id=user_id + ) + + # Get the specific task + task = await TaskService.get_task_by_id(session, user_id, task_id) + + logger.info(f"Successfully retrieved task {task_id} for user {user_id}") + return task + + except HTTPException: + # Re-raise HTTP exceptions (like 401, 403, 404) + raise + except Exception as e: + logger.error(f"Error retrieving task {task_id} for user {user_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error retrieving task" + ) + + +@router.put("/tasks/{task_id}", response_model=TaskRead) +async def update_task( + request: Request, + user_id: int, + task_id: int, + task_data: TaskUpdate, + token: HTTPAuthorizationCredentials = Depends(security), + session: AsyncSession = Depends(get_session_dep) +): + """ + Update a specific task for the specified user. + + Args: + request: FastAPI request object + user_id: The ID of the user + task_id: The ID of the task to update + task_data: Task update data + token: JWT token for authentication + session: Database session + + Returns: + Updated TaskRead object + + Raises: + HTTPException: If authentication fails, user_id validation fails, or task not found + """ + try: + # Extract and validate token + token_user_id = get_user_id_from_token(token.credentials) + + # Validate that token user_id matches URL user_id + validate_user_id_from_token( + request=request, + token_user_id=token_user_id, + url_user_id=user_id + ) + + # Update the task + updated_task = await TaskService.update_task(session, user_id, task_id, task_data) + + logger.info(f"Successfully updated task {task_id} for user {user_id}") + return updated_task + + except HTTPException: + # Re-raise HTTP exceptions (like 401, 403, 404) + raise + except Exception as e: + logger.error(f"Error updating task {task_id} for user {user_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error updating task" + ) + + +@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_task( + request: Request, + user_id: int, + task_id: int, + token: HTTPAuthorizationCredentials = Depends(security), + session: AsyncSession = Depends(get_session_dep) +): + """ + Delete a specific task for the specified user. + + Args: + request: FastAPI request object + user_id: The ID of the user + task_id: The ID of the task to delete + token: JWT token for authentication + session: Database session + + Raises: + HTTPException: If authentication fails, user_id validation fails, or task not found + """ + try: + # Extract and validate token + token_user_id = get_user_id_from_token(token.credentials) + + # Validate that token user_id matches URL user_id + validate_user_id_from_token( + request=request, + token_user_id=token_user_id, + url_user_id=user_id + ) + + # Delete the task + await TaskService.delete_task(session, user_id, task_id) + + logger.info(f"Successfully deleted task {task_id} for user {user_id}") + return + + except HTTPException: + # Re-raise HTTP exceptions (like 401, 403, 404) + raise + except Exception as e: + logger.error(f"Error deleting task {task_id} for user {user_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error deleting task" + ) + + +@router.patch("/tasks/{task_id}/complete", response_model=TaskRead) +async def update_task_completion( + request: Request, + user_id: int, + task_id: int, + completion_data: TaskComplete, + token: HTTPAuthorizationCredentials = Depends(security), + session: AsyncSession = Depends(get_session_dep) +): + """ + Update the completion status of a specific task for the specified user. + + Args: + request: FastAPI request object + user_id: The ID of the user + task_id: The ID of the task to update + completion_data: Task completion data + token: JWT token for authentication + session: Database session + + Returns: + Updated TaskRead object + + Raises: + HTTPException: If authentication fails, user_id validation fails, or task not found + """ + try: + # Extract and validate token + token_user_id = get_user_id_from_token(token.credentials) + + # Validate that token user_id matches URL user_id + validate_user_id_from_token( + request=request, + token_user_id=token_user_id, + url_user_id=user_id + ) + + # Update task completion status + updated_task = await TaskService.update_task_completion(session, user_id, task_id, completion_data) + + logger.info(f"Successfully updated completion status for task {task_id} for user {user_id}") + return updated_task + + except HTTPException: + # Re-raise HTTP exceptions (like 401, 403, 404) + raise + except Exception as e: + logger.error(f"Error updating completion status for task {task_id} for user {user_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error updating task completion status" + ) \ No newline at end of file diff --git a/api/v1/routes/users.py b/api/v1/routes/users.py new file mode 100644 index 0000000000000000000000000000000000000000..1c285201c79bbe28efdd4e7ec056c76b3c05aa61 --- /dev/null +++ b/api/v1/routes/users.py @@ -0,0 +1,89 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlmodel.ext.asyncio.session import AsyncSession +from ...database.session import get_session_dep +from ...schemas.user import UserRead, UserCreate +from ...services.user_service import UserService +from ...utils.logging import get_logger + +router = APIRouter() +logger = get_logger(__name__) + +@router.get("/users/{user_id}", response_model=UserRead) +async def get_user( + request: Request, + user_id: int, + session: AsyncSession = Depends(get_session_dep) +): + """ + Retrieve a specific user by ID. + + Args: + request: FastAPI request object + user_id: The ID of the user to retrieve + session: Database session + + Returns: + UserRead object + + Raises: + HTTPException: If user not found + """ + try: + user = await UserService.get_user_by_id(session, user_id) + + if not user: + logger.warning(f"User {user_id} not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + logger.info(f"Successfully retrieved user {user_id}") + return user + + except HTTPException: + # Re-raise HTTP exceptions + raise + except Exception as e: + logger.error(f"Error retrieving user {user_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error retrieving user" + ) + + +@router.post("/users", response_model=UserRead, status_code=status.HTTP_201_CREATED) +async def create_user( + request: Request, + user_data: UserCreate, + session: AsyncSession = Depends(get_session_dep) +): + """ + Create a new user. + + Args: + request: FastAPI request object + user_data: User creation data + session: Database session + + Returns: + Created UserRead object + + Raises: + HTTPException: If user creation fails + """ + try: + created_user = await UserService.create_user(session, user_data) + + logger.info(f"Successfully created user {created_user.id}") + return created_user + + except HTTPException: + # Re-raise HTTP exceptions + raise + except Exception as e: + logger.error(f"Error creating user: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error creating user" + ) \ No newline at end of file diff --git a/auth/__init__.py b/auth/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/auth/__pycache__/__init__.cpython-313.pyc b/auth/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a2242a3127040c9265f60c08e2374a7fb7e39ede Binary files /dev/null and b/auth/__pycache__/__init__.cpython-313.pyc differ diff --git a/auth/__pycache__/jwt_handler.cpython-313.pyc b/auth/__pycache__/jwt_handler.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aadb8651505f4a08f7ea563b3b6660d2451eb3fe Binary files /dev/null and b/auth/__pycache__/jwt_handler.cpython-313.pyc differ diff --git a/auth/jwt_handler.py b/auth/jwt_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..fbc4b21644c91488702439f193182d7caff04b5f --- /dev/null +++ b/auth/jwt_handler.py @@ -0,0 +1,150 @@ +import jwt +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from fastapi import HTTPException, status +from config.settings import settings +from models.user import UserRead + +def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: + """ + Create a new access token with the provided data. + + Args: + data: Dictionary containing the data to encode in the token + expires_delta: Optional timedelta for token expiration (defaults to settings value) + + Returns: + Encoded JWT token as string + """ + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes) + + to_encode.update({"exp": expire, "iat": datetime.utcnow(), "type": "access"}) + + encoded_jwt = jwt.encode( + to_encode, + settings.jwt_secret, + algorithm=settings.jwt_algorithm + ) + + return encoded_jwt + + +def create_refresh_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: + """ + Create a new refresh token with the provided data. + + Args: + data: Dictionary containing the data to encode in the token + expires_delta: Optional timedelta for token expiration (defaults to settings value) + + Returns: + Encoded JWT token as string + """ + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + # Default refresh token expiration to 7 days + expire = datetime.utcnow() + timedelta(days=settings.refresh_token_expire_days) + + to_encode.update({"exp": expire, "iat": datetime.utcnow(), "type": "refresh"}) + + encoded_jwt = jwt.encode( + to_encode, + settings.jwt_secret, + algorithm=settings.jwt_algorithm + ) + + return encoded_jwt + +def verify_token(token: str) -> Dict[str, Any]: + """ + Verify and decode a JWT token. + + Args: + token: JWT token string to verify + + Returns: + Decoded token payload as dictionary + + Raises: + HTTPException: If token is invalid, expired, or cannot be decoded + """ + try: + payload = jwt.decode( + token, + settings.jwt_secret, + algorithms=[settings.jwt_algorithm] + ) + + # Check if token is expired + if "exp" in payload: + exp_timestamp = payload["exp"] + if datetime.fromtimestamp(exp_timestamp) < datetime.utcnow(): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return payload + + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired", + headers={"WWW-Authenticate": "Bearer"}, + ) + except jwt.InvalidTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + +def get_user_id_from_token(token: str) -> int: + """ + Extract user ID from JWT token. + + Args: + token: JWT token string + + Returns: + User ID as integer + + Raises: + HTTPException: If token is invalid or user_id is not in token + """ + payload = verify_token(token) + + user_id = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Ensure user_id is an integer + try: + user_id = int(user_id) + except (ValueError, TypeError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid user ID in token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return user_id \ No newline at end of file diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/config/__pycache__/__init__.cpython-313.pyc b/config/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dd9c3ffdb9945e7cdbec7fd78581cea4bb1049fa Binary files /dev/null and b/config/__pycache__/__init__.cpython-313.pyc differ diff --git a/config/__pycache__/settings.cpython-313.pyc b/config/__pycache__/settings.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b37a1ec3d48634086115b60b733532a6a9dd50df Binary files /dev/null and b/config/__pycache__/settings.cpython-313.pyc differ diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..153f6dc6683c09b03e6810f16ba6738591afc1f5 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,35 @@ +from pydantic_settings import BaseSettings +from typing import Optional +import os +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +class Settings(BaseSettings): + """ + Application settings loaded from environment variables. + """ + # Database settings + database_url: str = os.getenv("DATABASE_URL", "postgresql+asyncpg://username:password@localhost:5432/todo_app") + db_echo: bool = os.getenv("DB_ECHO", "False").lower() == "true" + + # JWT settings + jwt_secret: str = os.getenv("BETTER_AUTH_SECRET", "your-super-secret-jwt-signing-key-here") + jwt_algorithm: str = os.getenv("JWT_ALGORITHM", "HS256") + access_token_expire_minutes: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30")) + refresh_token_expire_days: int = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7")) + + # Application settings + app_name: str = "Todo List API" + app_version: str = "1.0.0" + debug: bool = os.getenv("DEBUG", "False").lower() == "true" + + model_config = { + "env_file": ".env", + "case_sensitive": True, + "extra": "allow" + } + +# Create a settings instance +settings = Settings() \ No newline at end of file diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/database/__pycache__/__init__.cpython-313.pyc b/database/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..86017819a1532e9f4cf875bf85496fb9ddc38bf0 Binary files /dev/null and b/database/__pycache__/__init__.cpython-313.pyc differ diff --git a/database/__pycache__/session.cpython-313.pyc b/database/__pycache__/session.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c2743d7ea8b695f9f5c829436080f1eb6d1e80b2 Binary files /dev/null and b/database/__pycache__/session.cpython-313.pyc differ diff --git a/database/session.py b/database/session.py new file mode 100644 index 0000000000000000000000000000000000000000..c3884c035005b8d04c571e41a5113fb6f1f24d08 --- /dev/null +++ b/database/session.py @@ -0,0 +1,65 @@ +from sqlmodel.ext.asyncio.session import AsyncSession +from sqlalchemy.ext.asyncio import create_async_engine +from typing import AsyncGenerator +from contextlib import asynccontextmanager +import os +from config.settings import settings + +# Create the async database engine +db_url = settings.database_url + +if db_url.startswith("postgresql://"): + # Convert to asyncpg format + db_url = db_url.replace("postgresql://", "postgresql+asyncpg://", 1) +elif db_url.startswith("postgresql+asyncpg://"): + # Already in correct format + db_url = db_url +elif db_url.startswith("sqlite://") and not db_url.startswith("sqlite+aiosqlite"): + # Convert to aiosqlite format + db_url = db_url.replace("sqlite://", "sqlite+aiosqlite://", 1) +elif db_url.startswith("sqlite+aiosqlite"): + # Already in correct format + db_url = db_url + +# For Neon PostgreSQL with asyncpg, SSL is handled automatically +# The issue is with URL parameters that asyncpg doesn't expect +if "postgresql+asyncpg" in db_url and "?sslmode=" in db_url: + # Extract the base URL without query parameters + base_url = db_url.split('?')[0] + # For Neon, we often just need the base URL as asyncpg handles SSL automatically + db_url = base_url + +# Set appropriate engine options based on database type +if "postgresql" in db_url: + # For PostgreSQL, use asyncpg with proper SSL handling + async_engine = create_async_engine( + db_url, + echo=settings.db_echo, # Set to True for SQL query logging during development + pool_pre_ping=True, # Verify connections before use + pool_recycle=300, # Recycle connections every 5 minutes + # SSL is handled automatically by asyncpg for Neon + ) +else: # SQLite + async_engine = create_async_engine( + db_url, + echo=settings.db_echo, # Set to True for SQL query logging during development + ) + +@asynccontextmanager +async def get_async_session() -> AsyncGenerator[AsyncSession, None]: + """ + Async context manager for database sessions. + Ensures the session is properly closed after use. + """ + async with AsyncSession(async_engine) as session: + try: + yield session + finally: + await session.close() + +async def get_session_dep(): + """ + Dependency function for FastAPI to provide async database sessions. + """ + async with AsyncSession(async_engine) as session: + yield session \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..baef79a18411f590f7bf133f853482d2c7d23439 --- /dev/null +++ b/main.py @@ -0,0 +1,58 @@ +from fastapi import FastAPI +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.middleware.cors import CORSMiddleware +from api.v1.routes import tasks +from api.v1.routes import auth +from database.session import async_engine +from models import task, user # Import models to register them with SQLModel +from utils.exception_handlers import ( + http_exception_handler, + validation_exception_handler, + general_exception_handler +) +import sqlmodel + +app = FastAPI(title="Todo List API", version="1.0.0") + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, replace with specific origins + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Register exception handlers +app.add_exception_handler(StarletteHTTPException, http_exception_handler) +app.add_exception_handler(RequestValidationError, validation_exception_handler) +app.add_exception_handler(Exception, general_exception_handler) + +@app.on_event("startup") +async def startup(): + """Create database tables on startup""" + async with async_engine.begin() as conn: + await conn.run_sync(sqlmodel.SQLModel.metadata.create_all) + +# Include API routes +app.include_router(tasks.router, prefix="/api/{user_id}", tags=["tasks"]) +app.include_router(auth.router, prefix="/api", tags=["auth"]) + +@app.get("/") +def read_root(): + return {"message": "Todo List API - Phase II Backend"} + +@app.get("/health") +def health_check(): + return {"status": "healthy"} + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "main:app", + host="127.0.0.1", + port=8000, + reload=True, + ) diff --git a/middleware/__init__.py b/middleware/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/middleware/__pycache__/__init__.cpython-313.pyc b/middleware/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2ee02b7662aafdfbf57e3c63ee27959ed971553e Binary files /dev/null and b/middleware/__pycache__/__init__.cpython-313.pyc differ diff --git a/middleware/__pycache__/auth_middleware.cpython-313.pyc b/middleware/__pycache__/auth_middleware.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..17d4c223862b671bc8efe908efbfc461f043ca54 Binary files /dev/null and b/middleware/__pycache__/auth_middleware.cpython-313.pyc differ diff --git a/middleware/auth_middleware.py b/middleware/auth_middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..fbb7433ab8143115cf631f987de7a68bd7c74f0c --- /dev/null +++ b/middleware/auth_middleware.py @@ -0,0 +1,69 @@ +from fastapi import Request, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from auth.jwt_handler import verify_token, get_user_id_from_token +from typing import Optional, Dict, Any +import logging + +# Set up logger +logger = logging.getLogger(__name__) + +class JWTBearer(HTTPBearer): + """ + Custom JWT Bearer authentication scheme. + This class handles extracting and validating JWT tokens from request headers. + """ + def __init__(self, auto_error: bool = True): + super(JWTBearer, self).__init__(auto_error=auto_error) + + async def __call__(self, request: Request) -> Optional[Dict[str, Any]]: + """ + Extract and validate JWT token from request. + + Args: + request: FastAPI request object + + Returns: + Token payload if valid, None if auto_error is False and no token + + Raises: + HTTPException: If token is invalid or missing (when auto_error=True) + """ + credentials: HTTPAuthorizationCredentials = await super(JWTBearer, self).__call__(request) + + if credentials: + if not credentials.scheme == "Bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication scheme", + ) + + token = credentials.credentials + return verify_token(token) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authorization code", + ) + +def validate_user_id_from_token(request: Request, token_user_id: int, url_user_id: int) -> bool: + """ + Validate that the user_id in the JWT token matches the user_id in the URL. + + Args: + request: FastAPI request object (for logging) + token_user_id: User ID extracted from JWT token + url_user_id: User ID from the URL path parameter + + Returns: + True if user IDs match, raises HTTPException if they don't match + """ + if token_user_id != url_user_id: + logger.warning( + f"User ID mismatch - Token: {token_user_id}, URL: {url_user_id}, Path: {request.url.path}" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User ID in token does not match user ID in URL", + ) + + return True \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/models/__pycache__/__init__.cpython-313.pyc b/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aed5794eccfbe9de0d75109d75bbab2541d435c6 Binary files /dev/null and b/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/models/__pycache__/task.cpython-313.pyc b/models/__pycache__/task.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..99e9c2166c8946f92b513fbe4203e7b1ce91411a Binary files /dev/null and b/models/__pycache__/task.cpython-313.pyc differ diff --git a/models/__pycache__/user.cpython-313.pyc b/models/__pycache__/user.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..27371e0cfa9fb104845501fbd9fefbacbdc16745 Binary files /dev/null and b/models/__pycache__/user.cpython-313.pyc differ diff --git a/models/task.py b/models/task.py new file mode 100644 index 0000000000000000000000000000000000000000..2607ce9d43083dbef38fa1a778632d6a911d58c1 --- /dev/null +++ b/models/task.py @@ -0,0 +1,59 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional +from enum import Enum +from datetime import datetime +from .user import User + +class PriorityEnum(str, Enum): + high = "high" + medium = "medium" + low = "low" + +class TaskBase(SQLModel): + title: str = Field(nullable=False, max_length=255) + description: Optional[str] = Field(default=None, max_length=1000) + completed: Optional[bool] = Field(default=False) # Made optional to match frontend + priority: Optional[PriorityEnum] = Field(default=None) # Changed default to None to match frontend + due_date: Optional[str] = Field(default=None, max_length=50) # Changed from datetime to string to match frontend, added max_length for DB + +class Task(TaskBase, table=True): + """ + Represents a user's todo item with properties for content, status, and ownership. + """ + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="users.id", nullable=False) # Updated to match new table name + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + # Relationship to User + user: Optional[User] = Relationship(back_populates="tasks") + +class TaskCreate(TaskBase): + """Schema for creating a new task.""" + # Explicitly define fields to ensure they're properly inherited + title: str + description: Optional[str] = None + completed: Optional[bool] = False + priority: Optional[PriorityEnum] = None + due_date: Optional[str] = Field(default=None, max_length=50) + +class TaskRead(TaskBase): + """Schema for reading task data.""" + id: int + user_id: int + created_at: datetime + updated_at: datetime + priority: Optional[PriorityEnum] = None # Explicitly include new field + due_date: Optional[str] = Field(default=None, max_length=50) # Changed from datetime to string to match frontend + +class TaskUpdate(SQLModel): + """Schema for updating a task.""" + title: Optional[str] = None + description: Optional[str] = None + completed: Optional[bool] = None + priority: Optional[PriorityEnum] = None + due_date: Optional[str] = Field(default=None, max_length=50) # Changed from datetime to string to match frontend + +class TaskComplete(SQLModel): + """Schema for updating task completion status.""" + completed: bool \ No newline at end of file diff --git a/models/user.py b/models/user.py new file mode 100644 index 0000000000000000000000000000000000000000..8ed2c9cc7882e93227f925a3d6b7546318a2653f --- /dev/null +++ b/models/user.py @@ -0,0 +1,28 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, List +from datetime import datetime + +class UserBase(SQLModel): + email: str = Field(unique=True, nullable=False, max_length=255) + name: str = Field(nullable=False, max_length=255) + +class User(UserBase, table=True): + """ + Represents a registered user in the system with authentication information. + """ + __tablename__ = "users" # Use 'users' instead of 'user' to avoid PostgreSQL reserved keyword + + id: Optional[int] = Field(default=None, primary_key=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + + # Relationship to tasks + tasks: List["Task"] = Relationship(back_populates="user") + +class UserCreate(UserBase): + """Schema for creating a new user.""" + pass + +class UserRead(UserBase): + """Schema for reading user data.""" + id: int + created_at: datetime \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..5f9450b92fc31082241e2cb5e50991a6bf2fa928 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "backend" +version = "0.1.0" +description = "Todo List API Backend - Phase II" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "sqlmodel>=0.0.22", + "pydantic>=2.9.2", + "pydantic-settings>=2.6.1", + "pyjwt>=2.9.0", + "python-multipart>=0.0.12", + "uvicorn[standard]>=0.32.0", + "asyncpg>=0.30.0", + "python-dotenv>=1.0.1", + "pytest>=8.3.3", + "pytest-asyncio>=0.23.7", + "httpx>=0.27.2", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..d9ca003cb8d21fae6885de2e1221bae6eb24b4a9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.115.0 +sqlmodel==0.0.22 +pydantic==2.9.2 +pydantic-settings==2.6.1 +pyjwt==2.9.0 +python-multipart==0.0.12 +uvicorn[standard]==0.32.0 +asyncpg==0.30.0 +python-dotenv==1.0.1 +pytest==8.3.3 +pytest-asyncio==0.23.7 +httpx==0.27.2 \ No newline at end of file diff --git a/reset_database.py b/reset_database.py new file mode 100644 index 0000000000000000000000000000000000000000..ab195a345cacaa1b5e6bdba9c049db7395e15054 --- /dev/null +++ b/reset_database.py @@ -0,0 +1,28 @@ +""" +Simple script to recreate database tables +""" + +import asyncio +from sqlmodel import SQLModel +from database.session import async_engine +from models.user import User +from models.task import Task + +async def reset_database(): + print("Dropping and recreating database tables...") + + # Drop all tables first + async with async_engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.drop_all) + + print("Tables dropped.") + + # Create all tables + async with async_engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + print("Tables recreated successfully!") + print("Database reset complete.") + +if __name__ == "__main__": + asyncio.run(reset_database()) \ No newline at end of file diff --git a/s.py b/s.py new file mode 100644 index 0000000000000000000000000000000000000000..2e3d70a16f6f25e4ee516563c3e9b04cb5cce50e --- /dev/null +++ b/s.py @@ -0,0 +1,2 @@ +import secrets +print(secrets.token_hex(50)) diff --git a/schemas/__init__.py b/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/schemas/__pycache__/__init__.cpython-313.pyc b/schemas/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..26b98693390e36fb0348479088fa5997143898c3 Binary files /dev/null and b/schemas/__pycache__/__init__.cpython-313.pyc differ diff --git a/schemas/__pycache__/user.cpython-313.pyc b/schemas/__pycache__/user.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..559fdb33e52150ebc116b73e628a2bf82faf0673 Binary files /dev/null and b/schemas/__pycache__/user.cpython-313.pyc differ diff --git a/schemas/task.py b/schemas/task.py new file mode 100644 index 0000000000000000000000000000000000000000..920a6163da43e09d4b4cd8c8d8bb0c68bab2962f --- /dev/null +++ b/schemas/task.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + +class TaskBase(BaseModel): + title: str + description: Optional[str] = None + completed: bool = False + +class TaskCreate(TaskBase): + """Schema for creating a new task.""" + pass + +class TaskRead(TaskBase): + """Schema for reading task data.""" + id: int + user_id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +class TaskUpdate(BaseModel): + """Schema for updating a task.""" + title: Optional[str] = None + description: Optional[str] = None + completed: Optional[bool] = None + +class TaskComplete(BaseModel): + """Schema for updating task completion status.""" + completed: bool \ No newline at end of file diff --git a/schemas/user.py b/schemas/user.py new file mode 100644 index 0000000000000000000000000000000000000000..22b7986b2aaec9af92392a2d268cc7a784f1b3fc --- /dev/null +++ b/schemas/user.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + +class UserBase(BaseModel): + email: str + name: str + +class UserCreate(UserBase): + """Schema for creating a new user.""" + pass + +class UserRead(UserBase): + """Schema for reading user data.""" + id: int + created_at: datetime + + class Config: + from_attributes = True + +class UserUpdate(BaseModel): + """Schema for updating user data.""" + email: Optional[str] = None + name: Optional[str] = None \ No newline at end of file diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/services/__pycache__/__init__.cpython-313.pyc b/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8d6080e5f349c52a6eaa4b2ada4820a078b22e24 Binary files /dev/null and b/services/__pycache__/__init__.cpython-313.pyc differ diff --git a/services/__pycache__/task_service.cpython-313.pyc b/services/__pycache__/task_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7a2a43f53b06fdc62b10620e865ca9966d6453dd Binary files /dev/null and b/services/__pycache__/task_service.cpython-313.pyc differ diff --git a/services/__pycache__/user_service.cpython-313.pyc b/services/__pycache__/user_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..02e7fccaa923e65b727d2420a90f526e8dab1056 Binary files /dev/null and b/services/__pycache__/user_service.cpython-313.pyc differ diff --git a/services/task_service.py b/services/task_service.py new file mode 100644 index 0000000000000000000000000000000000000000..b4c216af3043cc6cab08e59d77e5d583e081f34a --- /dev/null +++ b/services/task_service.py @@ -0,0 +1,289 @@ +from typing import List, Optional +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession +from models.task import Task, TaskCreate, TaskUpdate, TaskComplete +from models.user import User +from models.task import TaskRead +from utils.logging import get_logger +from fastapi import HTTPException, status +from datetime import datetime +import asyncio + +logger = get_logger(__name__) + +class TaskService: + """ + Service class for handling task-related business logic with authorization. + """ + + @staticmethod + async def get_tasks_by_user_id(session: AsyncSession, user_id: int) -> List[TaskRead]: + """ + Get all tasks for a specific user. + + Args: + session: Database session + user_id: ID of the user whose tasks to retrieve + + Returns: + List of TaskRead objects + + Raises: + HTTPException: If database query fails + """ + try: + # Query tasks for the specific user + statement = select(Task).where(Task.user_id == user_id) + result = await session.exec(statement) + tasks = result.all() + + # Convert to response schema + task_list = [TaskRead.model_validate(task) for task in tasks] + + logger.info(f"Retrieved {len(task_list)} tasks for user {user_id}") + + return task_list + except Exception as e: + logger.error(f"Error retrieving tasks for user {user_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error retrieving tasks" + ) + + @staticmethod + async def get_task_by_id(session: AsyncSession, user_id: int, task_id: int) -> TaskRead: + """ + Get a specific task by ID for a specific user. + + Args: + session: Database session + user_id: ID of the user + task_id: ID of the task to retrieve + + Returns: + TaskRead object + + Raises: + HTTPException: If task doesn't exist or doesn't belong to user + """ + try: + # Query for the specific task that belongs to the user + statement = select(Task).where(Task.user_id == user_id, Task.id == task_id) + result = await session.exec(statement) + task = result.first() + + if not task: + logger.warning(f"Task {task_id} not found for user {user_id}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + logger.info(f"Retrieved task {task_id} for user {user_id}") + + return TaskRead.model_validate(task) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error retrieving task {task_id} for user {user_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error retrieving task" + ) + + @staticmethod + async def create_task(session: AsyncSession, user_id: int, task_data: TaskCreate) -> TaskRead: + """ + Create a new task for a specific user. + + Args: + session: Database session + user_id: ID of the user creating the task + task_data: Task creation data + + Returns: + Created TaskRead object + + Raises: + HTTPException: If task creation fails + """ + try: + # Create new task instance + db_task = Task( + user_id=user_id, + title=task_data.title, + description=task_data.description, + completed=task_data.completed, + priority=task_data.priority, + due_date=task_data.due_date + ) + + # Add to session and commit + session.add(db_task) + await session.commit() + await session.refresh(db_task) + + logger.info(f"Created task {db_task.id} for user {user_id}") + + return TaskRead.model_validate(db_task) + except Exception as e: + await session.rollback() + logger.error(f"Error creating task for user {user_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error creating task" + ) + + @staticmethod + async def update_task(session: AsyncSession, user_id: int, task_id: int, task_data: TaskUpdate) -> TaskRead: + """ + Update a specific task for a specific user. + + Args: + session: Database session + user_id: ID of the user + task_id: ID of the task to update + task_data: Task update data + + Returns: + Updated TaskRead object + + Raises: + HTTPException: If task doesn't exist or doesn't belong to user + """ + try: + # Query for the specific task that belongs to the user + statement = select(Task).where(Task.user_id == user_id, Task.id == task_id) + result = await session.exec(statement) + task = result.first() + + if not task: + logger.warning(f"Task {task_id} not found for user {user_id}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + # Update task fields if provided + update_data = task_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(task, field, value) + + # Update the updated_at timestamp + task.updated_at = datetime.utcnow() + + # Commit changes + session.add(task) + await session.commit() + await session.refresh(task) + + logger.info(f"Updated task {task_id} for user {user_id}") + + return TaskRead.model_validate(task) + except HTTPException: + raise + except Exception as e: + await session.rollback() + logger.error(f"Error updating task {task_id} for user {user_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error updating task" + ) + + @staticmethod + async def delete_task(session: AsyncSession, user_id: int, task_id: int) -> bool: + """ + Delete a specific task for a specific user. + + Args: + session: Database session + user_id: ID of the user + task_id: ID of the task to delete + + Returns: + True if task was deleted successfully + + Raises: + HTTPException: If task doesn't exist or doesn't belong to user + """ + try: + # Query for the specific task that belongs to the user + statement = select(Task).where(Task.user_id == user_id, Task.id == task_id) + result = await session.exec(statement) + task = result.first() + + if not task: + logger.warning(f"Task {task_id} not found for user {user_id}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + # Delete the task + await session.delete(task) + await session.commit() + + logger.info(f"Deleted task {task_id} for user {user_id}") + + return True + except HTTPException: + raise + except Exception as e: + await session.rollback() + logger.error(f"Error deleting task {task_id} for user {user_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error deleting task" + ) + + @staticmethod + async def update_task_completion(session: AsyncSession, user_id: int, task_id: int, completion_data: TaskComplete) -> TaskRead: + """ + Update the completion status of a specific task for a specific user. + + Args: + session: Database session + user_id: ID of the user + task_id: ID of the task to update + completion_data: Task completion data + + Returns: + Updated TaskRead object + + Raises: + HTTPException: If task doesn't exist or doesn't belong to user + """ + try: + # Query for the specific task that belongs to the user + statement = select(Task).where(Task.user_id == user_id, Task.id == task_id) + result = await session.exec(statement) + task = result.first() + + if not task: + logger.warning(f"Task {task_id} not found for user {user_id}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + # Update completion status + task.completed = completion_data.completed + task.updated_at = datetime.utcnow() + + # Commit changes + session.add(task) + await session.commit() + await session.refresh(task) + + logger.info(f"Updated completion status for task {task_id} for user {user_id}") + + return TaskRead.model_validate(task) + except HTTPException: + raise + except Exception as e: + await session.rollback() + logger.error(f"Error updating completion status for task {task_id} for user {user_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error updating task completion status" + ) \ No newline at end of file diff --git a/services/user_service.py b/services/user_service.py new file mode 100644 index 0000000000000000000000000000000000000000..ffab5941830a4cbfc67be6cb69d089339477353a --- /dev/null +++ b/services/user_service.py @@ -0,0 +1,123 @@ +from typing import Optional +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession +from models.user import User, UserCreate +from schemas.user import UserRead +from utils.logging import get_logger +from fastapi import HTTPException, status +import asyncio + +logger = get_logger(__name__) + +class UserService: + """ + Service class for handling user-related business logic. + """ + + @staticmethod + async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[UserRead]: + """ + Get a user by ID. + + Args: + session: Database session + user_id: ID of the user to retrieve + + Returns: + UserRead object if found, None otherwise + """ + try: + statement = select(User).where(User.id == user_id) + result = await session.exec(statement) + user = result.first() + + if user: + logger.info(f"Retrieved user {user_id}") + return UserRead.model_validate(user) + else: + logger.warning(f"User {user_id} not found") + return None + except Exception as e: + logger.error(f"Error retrieving user {user_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error retrieving user" + ) + + @staticmethod + async def get_user_by_email(session: AsyncSession, email: str) -> Optional[User]: + """ + Get a user by email. + + Args: + session: Database session + email: Email of the user to retrieve + + Returns: + User object if found, None otherwise + """ + try: + statement = select(User).where(User.email == email) + result = await session.exec(statement) + user = result.first() + + if user: + logger.info(f"Retrieved user with email {email}") + return user + else: + logger.info(f"User with email {email} not found") + return None + except Exception as e: + logger.error(f"Error retrieving user with email {email}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error retrieving user" + ) + + @staticmethod + async def create_user(session: AsyncSession, user_data: UserCreate) -> UserRead: + """ + Create a new user. + + Args: + session: Database session + user_data: User creation data + + Returns: + Created UserRead object + + Raises: + HTTPException: If user creation fails + """ + try: + # Check if user already exists + existing_user = await UserService.get_user_by_email(session, user_data.email) + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User with this email already exists" + ) + + # Create new user instance + db_user = User( + email=user_data.email, + name=user_data.name + ) + + # Add to session and commit + session.add(db_user) + await session.commit() + await session.refresh(db_user) + + logger.info(f"Created user {db_user.id} with email {db_user.email}") + + return UserRead.model_validate(db_user) + except HTTPException: + raise + except Exception as e: + await session.rollback() + logger.error(f"Error creating user with email {user_data.email}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error creating user" + ) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/test_backend.py b/tests/test_backend.py new file mode 100644 index 0000000000000000000000000000000000000000..c2669cd0fdb3cae8fcd86b23ee65f57be4c5b05e --- /dev/null +++ b/tests/test_backend.py @@ -0,0 +1,301 @@ +""" +Backend API Test Suite for Todo List App Phase II + +This test suite verifies all backend functionality including: +- Authentication endpoints (register, login, logout) +- Task management endpoints (CRUD operations) +- User management endpoints +- JWT authentication and authorization +- Database connectivity +""" + +import requests +import json +from datetime import datetime + +# Base URL for the backend API +BASE_URL = "http://localhost:8000" + +def test_health_check(): + """Test the health check endpoint""" + print("Testing health check endpoint...") + try: + response = requests.get(f"{BASE_URL}/") + print(f"Health check: {response.status_code} - {response.json()}") + return response.status_code == 200 + except Exception as e: + print(f"Health check failed: {e}") + return False + +def test_register_user(): + """Test user registration""" + print("Testing user registration...") + try: + # Generate unique email for testing + timestamp = str(int(datetime.now().timestamp())) + user_data = { + "email": f"testuser_{timestamp}@example.com", + "password": "securepassword123", + "name": f"Test User {timestamp}" + } + + response = requests.post(f"{BASE_URL}/api/auth/register", + json=user_data, + headers={"Content-Type": "application/json"}) + + print(f"Registration: {response.status_code}") + if response.status_code == 201: + result = response.json() + print(f"User registered successfully: {result['user']['email']}") + return result['token'], result['user']['id'] + else: + print(f"Registration failed: {response.status_code} - {response.text}") + return None, None + except Exception as e: + print(f"Registration test failed: {e}") + return None, None + +def test_login_user(): + """Test user login""" + print("Testing user login...") + try: + # First register a user to login with + timestamp = str(int(datetime.now().timestamp())) + register_data = { + "email": f"login_test_{timestamp}@example.com", + "password": "securepassword123", + "name": f"Login Test {timestamp}" + } + + register_response = requests.post(f"{BASE_URL}/api/auth/register", + json=register_data, + headers={"Content-Type": "application/json"}) + + if register_response.status_code != 201: + print(f"Failed to create test user for login: {register_response.status_code}") + return None + + # Now try to login with the same credentials + login_data = { + "email": register_data["email"], + "password": register_data["password"] + } + + response = requests.post(f"{BASE_URL}/api/auth/login", + json=login_data, + headers={"Content-Type": "application/json"}) + + print(f"Login: {response.status_code}") + if response.status_code == 200: + result = response.json() + print(f"User logged in successfully: {result['user']['email']}") + return result['token'] + else: + print(f"Login failed: {response.status_code} - {response.text}") + return None + except Exception as e: + print(f"Login test failed: {e}") + return None + +def test_task_crud_operations(token, user_id): + """Test all task CRUD operations with authentication""" + print("Testing task CRUD operations...") + + if not token: + print("No valid token provided for task operations") + return False + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {token}" + } + + # Test 1: Create a task + print(" Creating task...") + task_data = { + "title": "Test Task", + "description": "This is a test task", + "completed": False + } + + try: + response = requests.post(f"{BASE_URL}/api/{user_id}/tasks", + json=task_data, + headers=headers) + + print(f" Create task: {response.status_code}") + if response.status_code != 201: + print(f" Create task failed: {response.text}") + return False + + created_task = response.json() + task_id = created_task['id'] + print(f" Task created with ID: {task_id}") + + # Test 2: Get all tasks for user + print(" Getting all tasks...") + response = requests.get(f"{BASE_URL}/api/{user_id}/tasks", headers=headers) + print(f" Get tasks: {response.status_code}") + if response.status_code != 200: + print(f" Get tasks failed: {response.text}") + return False + + tasks = response.json() + print(f" Retrieved {len(tasks)} tasks") + + # Test 3: Get specific task + print(" Getting specific task...") + response = requests.get(f"{BASE_URL}/api/{user_id}/tasks/{task_id}", headers=headers) + print(f" Get specific task: {response.status_code}") + if response.status_code != 200: + print(f" Get specific task failed: {response.text}") + return False + + retrieved_task = response.json() + print(f" Retrieved task: {retrieved_task['title']}") + + # Test 4: Update task + print(" Updating task...") + update_data = { + "title": "Updated Test Task", + "description": "This is an updated test task", + "completed": True + } + + response = requests.put(f"{BASE_URL}/api/{user_id}/tasks/{task_id}", + json=update_data, + headers=headers) + print(f" Update task: {response.status_code}") + if response.status_code != 200: + print(f" Update task failed: {response.text}") + return False + + updated_task = response.json() + print(f" Task updated: {updated_task['title']}") + + # Test 5: Update task completion status + print(" Updating task completion status...") + completion_data = {"completed": False} + + response = requests.patch(f"{BASE_URL}/api/{user_id}/tasks/{task_id}/complete", + json=completion_data, + headers=headers) + print(f" Update completion: {response.status_code}") + if response.status_code != 200: + print(f" Update completion failed: {response.text}") + return False + + completed_task = response.json() + print(f" Completion updated: {completed_task['completed']}") + + # Test 6: Delete task + print(" Deleting task...") + response = requests.delete(f"{BASE_URL}/api/{user_id}/tasks/{task_id}", headers=headers) + print(f" Delete task: {response.status_code}") + if response.status_code != 204: + print(f" Delete task failed: {response.text}") + return False + + print(" Task deleted successfully") + return True + + except Exception as e: + print(f"Task CRUD operations test failed: {e}") + return False + +def test_logout_user(token): + """Test user logout""" + print("Testing user logout...") + if not token: + print("No valid token provided for logout") + return False + + try: + headers = { + "Authorization": f"Bearer {token}" + } + + response = requests.post(f"{BASE_URL}/api/auth/logout", headers=headers) + print(f"Logout: {response.status_code}") + if response.status_code == 200: + print("User logged out successfully") + return True + else: + print(f"Logout failed: {response.text}") + return False + except Exception as e: + print(f"Logout test failed: {e}") + return False + +def test_invalid_token(): + """Test API with invalid token""" + print("Testing API with invalid token...") + try: + headers = { + "Authorization": "Bearer invalid_token_here" + } + + # Try to access a protected endpoint + response = requests.get(f"{BASE_URL}/api/1/tasks", headers=headers) + print(f"Invalid token test: {response.status_code}") + # Should return 401 for invalid token + return response.status_code == 401 + except Exception as e: + print(f"Invalid token test failed: {e}") + return False + +def run_all_tests(): + """Run all backend tests""" + print("=" * 60) + print("Starting Backend API Tests") + print("=" * 60) + + results = {} + + # Test 1: Health check + results['health_check'] = test_health_check() + + # Test 2: User registration + token, user_id = test_register_user() + results['registration'] = token is not None + + # Test 3: User login + login_token = test_login_user() + results['login'] = login_token is not None + + # Test 4: Task CRUD operations (if we have a valid token from registration) + if token and user_id: + results['task_crud'] = test_task_crud_operations(token, user_id) + else: + results['task_crud'] = False + print("Skipping task CRUD tests - no valid token from registration") + + # Test 5: Logout (if we have a valid login token) + if login_token: + results['logout'] = test_logout_user(login_token) + else: + results['logout'] = False + print("Skipping logout test - no valid login token") + + # Test 6: Invalid token handling + results['invalid_token'] = test_invalid_token() + + print("=" * 60) + print("Test Results Summary:") + print("=" * 60) + + all_passed = True + for test_name, result in results.items(): + status = "PASS" if result else "FAIL" + print(f"{test_name:20}: {status}") + if not result: + all_passed = False + + print("=" * 60) + print(f"Overall Result: {'ALL TESTS PASSED' if all_passed else 'SOME TESTS FAILED'}") + print("=" * 60) + + return all_passed + +if __name__ == "__main__": + run_all_tests() \ No newline at end of file diff --git a/tests/test_backend_final.py b/tests/test_backend_final.py new file mode 100644 index 0000000000000000000000000000000000000000..fbbefa3e212d11b9cc0bbc1dda452bae25a40bf4 --- /dev/null +++ b/tests/test_backend_final.py @@ -0,0 +1,153 @@ +""" +Quick test to verify the backend API with new fields is working +""" + +import requests +import json +from datetime import datetime + +BASE_URL = "http://localhost:8000" # Server running on port 8000 + +def test_backend(): + print("Testing Backend API with new fields...") + print("=" * 50) + + # Test 1: Health check + print("1. Testing health endpoint...") + try: + response = requests.get(f"{BASE_URL}/") + if response.status_code == 200: + print(" ✅ Health check: OK") + else: + print(f" ❌ Health check failed: {response.status_code}") + return False + except Exception as e: + print(f" ❌ Health check failed: {e}") + return False + + # Test 2: Register a user + print("2. Testing user registration...") + try: + timestamp = str(int(datetime.now().timestamp())) + user_data = { + "email": f"test_{timestamp}@example.com", + "password": "password123", + "name": f"Test User {timestamp}" + } + + response = requests.post(f"{BASE_URL}/api/auth/register", + json=user_data, + headers={"Content-Type": "application/json"}) + + if response.status_code == 201: + result = response.json() + token = result['token'] + user_id = result['user']['id'] + print(f" ✅ User registration: OK (User ID: {user_id})") + else: + print(f" ❌ User registration failed: {response.status_code} - {response.text}") + return False + except Exception as e: + print(f" ❌ User registration failed: {e}") + return False + + # Test 3: Create a task with new fields + print("3. Testing task creation with new fields...") + try: + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + task_data = { + "title": "Test Task with New Fields", + "description": "Task with priority and due date", + "completed": False, + "priority": "high", # NEW FIELD + "due_date": "2025-12-31T23:59:59" # NEW FIELD + } + + response = requests.post(f"{BASE_URL}/api/{user_id}/tasks", + json=task_data, + headers=headers) + + if response.status_code == 201: + result = response.json() + task_id = result['id'] + print(f" ✅ Task creation with new fields: OK (Task ID: {task_id})") + + # Verify new fields are present in response + if 'priority' in result and 'due_date' in result: + print(f" ✅ New fields present in response: priority={result['priority']}, due_date={result['due_date']}") + else: + print(f" ⚠️ New fields missing from response: {result.keys()}") + else: + print(f" ❌ Task creation failed: {response.status_code} - {response.text}") + return False + except Exception as e: + print(f" ❌ Task creation failed: {e}") + return False + + # Test 4: Get the task back + print("4. Testing task retrieval...") + try: + response = requests.get(f"{BASE_URL}/api/{user_id}/tasks/{task_id}", headers=headers) + + if response.status_code == 200: + result = response.json() + print(f" ✅ Task retrieval: OK") + + # Verify new fields are preserved + if result.get('priority') == 'high': + print(f" ✅ Priority field preserved: {result['priority']}") + else: + print(f" ⚠️ Priority field not preserved: {result.get('priority')}") + + if 'due_date' in result and result['due_date']: + print(f" ✅ Due date field preserved: {result['due_date']}") + else: + print(f" ⚠️ Due date field not preserved: {result.get('due_date')}") + else: + print(f" ❌ Task retrieval failed: {response.status_code} - {response.text}") + return False + except Exception as e: + print(f" ❌ Task retrieval failed: {e}") + return False + + # Test 5: Update task with new fields + print("5. Testing task update with new fields...") + try: + update_data = { + "priority": "low", + "due_date": "2026-01-15T10:30:00" + } + + response = requests.put(f"{BASE_URL}/api/{user_id}/tasks/{task_id}", + json=update_data, + headers=headers) + + if response.status_code == 200: + result = response.json() + print(f" ✅ Task update with new fields: OK") + + if result.get('priority') == 'low': + print(f" ✅ Priority updated successfully: {result['priority']}") + else: + print(f" ⚠️ Priority not updated: {result.get('priority')}") + else: + print(f" ❌ Task update failed: {response.status_code} - {response.text}") + return False + except Exception as e: + print(f" ❌ Task update failed: {e}") + return False + + print("=" * 50) + print("🎉 ALL TESTS PASSED! Backend API with new fields is working correctly!") + print("✅ Priority and due_date fields are fully functional") + print("✅ All CRUD operations work with new fields") + print("=" * 50) + + return True + +if __name__ == "__main__": + test_backend() \ No newline at end of file diff --git a/tests/test_endpoints_detailed.py b/tests/test_endpoints_detailed.py new file mode 100644 index 0000000000000000000000000000000000000000..c26d4e0d2d231ddcf873508d0bd27d3ac70baaa8 --- /dev/null +++ b/tests/test_endpoints_detailed.py @@ -0,0 +1,211 @@ +""" +Detailed API Endpoint Verification Script + +This script tests each API endpoint individually to verify functionality. +""" + +import requests +import json +from datetime import datetime + +# Base URL for the backend API +BASE_URL = "http://localhost:8000" + +def test_endpoint(endpoint, method="GET", data=None, headers=None, expected_status=200, description=""): + """Test a specific endpoint""" + print(f"Testing {description} - {method} {endpoint}") + + try: + url = f"{BASE_URL}{endpoint}" + response = requests.request(method, url, json=data, headers=headers) + + print(f" Status: {response.status_code} (Expected: {expected_status})") + if response.status_code == expected_status: + print(f" Result: PASS") + if response.content: # Check if there's response content + try: + print(f" Response: {response.json()}") + except: + print(f" Response: {response.text[:100]}...") # First 100 chars + return True + else: + print(f" Result: FAIL - {response.text}") + return False + except Exception as e: + print(f" Result: FAIL - Error: {e}") + return False + +def test_all_endpoints(): + """Test all API endpoints""" + print("=" * 70) + print("Detailed API Endpoint Verification") + print("=" * 70) + + results = {} + + # Test 1: Health check endpoint + results['health'] = test_endpoint("/", "GET", expected_status=200, + description="Health Check") + + # Test 2: Documentation endpoint + results['docs'] = test_endpoint("/docs", "GET", expected_status=200, + description="API Documentation") + + # Test 3: Register a user first for authentication tests + print("\n--- Setting up user for authentication tests ---") + timestamp = str(int(datetime.now().timestamp())) + user_data = { + "email": f"testuser_{timestamp}@example.com", + "password": "securepassword123", + "name": f"Test User {timestamp}" + } + + register_response = requests.post(f"{BASE_URL}/api/auth/register", + json=user_data, + headers={"Content-Type": "application/json"}) + + if register_response.status_code == 201: + register_result = register_response.json() + token = register_result['token'] + user_id = register_result['user']['id'] + print(f"User registered successfully. Token: {token[:20]}..., User ID: {user_id}") + else: + print(f"Failed to register user: {register_response.status_code} - {register_response.text}") + token = None + user_id = None + + # Test 4: Authentication endpoints + if token: + auth_headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + # Test login with the same credentials + login_data = {"email": user_data["email"], "password": user_data["password"]} + results['auth_login'] = test_endpoint("/api/auth/login", "POST", + data=login_data, expected_status=200, + description="User Login") + + # Test logout + results['auth_logout'] = test_endpoint("/api/auth/logout", "POST", + headers={"Authorization": f"Bearer {token}"}, + expected_status=200, + description="User Logout") + + # Test 5: Task endpoints (need to register another user and get a fresh token) + print("\n--- Setting up user for task tests ---") + timestamp2 = str(int(datetime.now().timestamp())) + task_user_data = { + "email": f"taskuser_{timestamp2}@example.com", + "password": "securepassword123", + "name": f"Task User {timestamp2}" + } + + task_register_response = requests.post(f"{BASE_URL}/api/auth/register", + json=task_user_data, + headers={"Content-Type": "application/json"}) + + if task_register_response.status_code == 201: + task_user_result = task_register_response.json() + task_token = task_user_result['token'] + task_user_id = task_user_result['user']['id'] + task_headers = {"Authorization": f"Bearer {task_token}", "Content-Type": "application/json"} + print(f"Task user registered. Token: {task_token[:20]}..., User ID: {task_user_id}") + else: + print(f"Failed to register task user: {task_register_response.status_code}") + task_token = None + task_user_id = None + task_headers = None + + # Test 6: Task CRUD operations + if task_token and task_user_id and task_headers: + # Create a task + task_data = { + "title": "Test Task for Endpoint Verification", + "description": "This is a test task for endpoint verification", + "completed": False + } + + # Create task + create_task_response = requests.post(f"{BASE_URL}/api/{task_user_id}/tasks", + json=task_data, headers=task_headers) + if create_task_response.status_code == 201: + created_task = create_task_response.json() + task_id = created_task['id'] + print(f"Task created with ID: {task_id}") + + # Test get all tasks + results['get_all_tasks'] = test_endpoint(f"/api/{task_user_id}/tasks", "GET", + headers=task_headers, expected_status=200, + description="Get All Tasks") + + # Test get specific task + results['get_task'] = test_endpoint(f"/api/{task_user_id}/tasks/{task_id}", "GET", + headers=task_headers, expected_status=200, + description="Get Specific Task") + + # Test update task + update_data = { + "title": "Updated Test Task", + "description": "This is an updated test task", + "completed": True + } + results['update_task'] = test_endpoint(f"/api/{task_user_id}/tasks/{task_id}", "PUT", + data=update_data, headers=task_headers, expected_status=200, + description="Update Task") + + # Test update task completion + completion_data = {"completed": False} + results['update_completion'] = test_endpoint(f"/api/{task_user_id}/tasks/{task_id}/complete", "PATCH", + data=completion_data, headers=task_headers, expected_status=200, + description="Update Task Completion") + + # Test delete task + results['delete_task'] = test_endpoint(f"/api/{task_user_id}/tasks/{task_id}", "DELETE", + headers=task_headers, expected_status=204, + description="Delete Task") + else: + print(f"Failed to create task: {create_task_response.status_code} - {create_task_response.text}") + # Mark all task tests as failed + results['get_all_tasks'] = False + results['get_task'] = False + results['update_task'] = False + results['update_completion'] = False + results['delete_task'] = False + else: + print("Skipping task tests - no valid user/token") + # Mark all task tests as failed + results['get_all_tasks'] = False + results['get_task'] = False + results['update_task'] = False + results['update_completion'] = False + results['delete_task'] = False + + # Test 7: Test protected endpoint without authentication (should fail) + results['unauthorized_access'] = test_endpoint(f"/api/{task_user_id if task_user_id else 1}/tasks", "GET", + expected_status=401, # Should be 401 Unauthorized + description="Unauthorized Access (should fail)") + + # Test 8: Test with invalid token (should fail) + invalid_headers = {"Authorization": "Bearer invalid_token_here", "Content-Type": "application/json"} + results['invalid_token'] = test_endpoint(f"/api/{task_user_id if task_user_id else 1}/tasks", "GET", + headers=invalid_headers, expected_status=401, + description="Invalid Token (should fail)") + + print("\n" + "=" * 70) + print("Endpoint Verification Results:") + print("=" * 70) + + all_passed = True + for endpoint, result in results.items(): + status = "PASS" if result else "FAIL" + print(f"{endpoint:25}: {status}") + if not result: + all_passed = False + + print("=" * 70) + print(f"Overall Result: {'ALL ENDPOINTS WORKING' if all_passed else 'SOME ENDPOINTS FAILED'}") + print("=" * 70) + + return all_passed + +if __name__ == "__main__": + test_all_endpoints() \ No newline at end of file diff --git a/tests/test_new_fields.py b/tests/test_new_fields.py new file mode 100644 index 0000000000000000000000000000000000000000..fd5ffebcbc416b8a2ce4c8553346ef4222d307b0 --- /dev/null +++ b/tests/test_new_fields.py @@ -0,0 +1,112 @@ +""" +Test script to verify the new Task model fields work correctly +""" + +import requests +import json +from datetime import datetime + +BASE_URL = "http://localhost:8001" + +def test_new_task_fields(): + print("Testing new Task model fields (priority, due_date)...") + + # Register a test user + timestamp = str(int(datetime.now().timestamp())) + user_data = { + "email": f"testuser_{timestamp}@example.com", + "password": "securepassword123", + "name": f"Test User {timestamp}" + } + + response = requests.post(f"{BASE_URL}/api/auth/register", + json=user_data, + headers={"Content-Type": "application/json"}) + + if response.status_code != 201: + print(f"Failed to register user: {response.status_code}") + return False + + result = response.json() + token = result['token'] + user_id = result['user']['id'] + print(f"User registered: {user_id}") + + # Create a task with new fields + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + task_data = { + "title": "Test Task with Priority", + "description": "This is a test task with priority and due date", + "completed": False, + "priority": "high", + "due_date": "2025-12-31T23:59:59" + } + + response = requests.post(f"{BASE_URL}/api/{user_id}/tasks", + json=task_data, + headers=headers) + + if response.status_code != 201: + print(f"Failed to create task with new fields: {response.status_code} - {response.text}") + return False + + task_result = response.json() + print(f"Task created with new fields: {task_result}") + + # Verify the response contains the new fields + if 'priority' in task_result and 'due_date' in task_result: + print("✅ New fields (priority, due_date) are present in response") + else: + print("❌ New fields are missing from response") + return False + + # Get the task back to verify it was stored correctly + response = requests.get(f"{BASE_URL}/api/{user_id}/tasks/{task_result['id']}", headers=headers) + if response.status_code != 200: + print(f"Failed to retrieve task: {response.status_code}") + return False + + retrieved_task = response.json() + print(f"Retrieved task: {retrieved_task}") + + # Verify the new fields are preserved + if (retrieved_task.get('priority') == 'high' and + 'due_date' in retrieved_task and + retrieved_task.get('title') == 'Test Task with Priority'): + print("✅ New fields are correctly stored and retrieved") + else: + print("❌ New fields are not correctly stored or retrieved") + return False + + # Test updating the task with new fields + update_data = { + "priority": "low", + "due_date": "2026-01-15T10:30:00" + } + + response = requests.put(f"{BASE_URL}/api/{user_id}/tasks/{task_result['id']}", + json=update_data, + headers=headers) + + if response.status_code != 200: + print(f"Failed to update task with new fields: {response.status_code}") + return False + + updated_task = response.json() + print(f"Updated task: {updated_task}") + + if updated_task.get('priority') == 'low': + print("✅ Task update with new fields works correctly") + else: + print("❌ Task update with new fields failed") + return False + + print("\n🎉 All tests passed! New Task fields are working correctly!") + return True + +if __name__ == "__main__": + test_new_task_fields() \ No newline at end of file diff --git a/tests/test_workflow.py b/tests/test_workflow.py new file mode 100644 index 0000000000000000000000000000000000000000..cf6b46859742e35c217d417e485c4df7a73679d1 --- /dev/null +++ b/tests/test_workflow.py @@ -0,0 +1,184 @@ +""" +Final Comprehensive API Test + +This test verifies the complete workflow of the Todo List API. +""" + +import requests +import json +from datetime import datetime + +BASE_URL = "http://localhost:8000" + +def test_complete_workflow(): + print("Testing Complete API Workflow") + print("=" * 50) + + # 1. Register a new user + print("1. Registering new user...") + timestamp = str(int(datetime.now().timestamp())) + user_data = { + "email": f"workflow_test_{timestamp}@example.com", + "password": "securepassword123", + "name": f"Workflow Test {timestamp}" + } + + response = requests.post(f"{BASE_URL}/api/auth/register", + json=user_data, + headers={"Content-Type": "application/json"}) + + if response.status_code != 201: + print(f" FAILED: Registration failed with status {response.status_code}") + return False + + result = response.json() + token = result['token'] + user_id = result['user']['id'] + print(f" SUCCESS: User {result['user']['email']} registered with ID {user_id}") + + # 2. Login with the same user + print("2. Logging in user...") + login_data = { + "email": user_data['email'], + "password": user_data['password'] + } + + response = requests.post(f"{BASE_URL}/api/auth/login", + json=login_data, + headers={"Content-Type": "application/json"}) + + if response.status_code != 200: + print(f" FAILED: Login failed with status {response.status_code}") + return False + + result = response.json() + new_token = result['token'] + print(f" SUCCESS: User logged in successfully") + + # 3. Create multiple tasks + print("3. Creating tasks...") + headers = { + "Authorization": f"Bearer {new_token}", + "Content-Type": "application/json" + } + + tasks_to_create = [ + {"title": "First Task", "description": "This is the first task", "completed": False}, + {"title": "Second Task", "description": "This is the second task", "completed": True}, + {"title": "Third Task", "description": "This is the third task", "completed": False} + ] + + created_tasks = [] + for i, task_data in enumerate(tasks_to_create): + response = requests.post(f"{BASE_URL}/api/{user_id}/tasks", + json=task_data, + headers=headers) + + if response.status_code != 201: + print(f" FAILED: Creating task {i+1} failed with status {response.status_code}") + return False + + task = response.json() + created_tasks.append(task) + print(f" SUCCESS: Task '{task['title']}' created with ID {task['id']}") + + # 4. Get all tasks + print("4. Retrieving all tasks...") + response = requests.get(f"{BASE_URL}/api/{user_id}/tasks", headers=headers) + + if response.status_code != 200: + print(f" FAILED: Getting tasks failed with status {response.status_code}") + return False + + tasks = response.json() + print(f" SUCCESS: Retrieved {len(tasks)} tasks") + + # 5. Get specific task + print("5. Retrieving specific task...") + first_task_id = created_tasks[0]['id'] + response = requests.get(f"{BASE_URL}/api/{user_id}/tasks/{first_task_id}", headers=headers) + + if response.status_code != 200: + print(f" FAILED: Getting specific task failed with status {response.status_code}") + return False + + task = response.json() + print(f" SUCCESS: Retrieved task '{task['title']}'") + + # 6. Update a task + print("6. Updating a task...") + update_data = { + "title": "Updated First Task", + "description": "This is the updated first task", + "completed": True + } + + response = requests.put(f"{BASE_URL}/api/{user_id}/tasks/{first_task_id}", + json=update_data, + headers=headers) + + if response.status_code != 200: + print(f" FAILED: Updating task failed with status {response.status_code}") + return False + + updated_task = response.json() + print(f" SUCCESS: Task updated to '{updated_task['title']}'") + + # 7. Update task completion status + print("7. Updating task completion status...") + completion_data = {"completed": False} + + response = requests.patch(f"{BASE_URL}/api/{user_id}/tasks/{first_task_id}/complete", + json=completion_data, + headers=headers) + + if response.status_code != 200: + print(f" FAILED: Updating completion status failed with status {response.status_code}") + return False + + completed_task = response.json() + print(f" SUCCESS: Task completion updated to {completed_task['completed']}") + + # 8. Delete a task + print("8. Deleting a task...") + response = requests.delete(f"{BASE_URL}/api/{user_id}/tasks/{first_task_id}", headers=headers) + + if response.status_code != 204: + print(f" FAILED: Deleting task failed with status {response.status_code}") + return False + + print(f" SUCCESS: Task deleted") + + # 9. Logout + print("9. Logging out user...") + response = requests.post(f"{BASE_URL}/api/auth/logout", headers=headers) + + if response.status_code != 200: + print(f" FAILED: Logout failed with status {response.status_code}") + return False + + print(f" SUCCESS: User logged out") + + # 10. Test unauthorized access + print("10. Testing unauthorized access...") + response = requests.get(f"{BASE_URL}/api/{user_id}/tasks") + + if response.status_code != 401: + print(f" FAILED: Unauthorized access should return 401, got {response.status_code}") + return False + + print(f" SUCCESS: Unauthorized access properly blocked ({response.status_code})") + + print("\n" + "=" * 50) + print("✅ ALL WORKFLOW TESTS PASSED!") + print("✅ API is fully functional!") + print("=" * 50) + + return True + +if __name__ == "__main__": + success = test_complete_workflow() + if success: + print("\n🎉 The Todo List API is working perfectly! 🎉") + else: + print("\n❌ Some tests failed") \ No newline at end of file diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/utils/__pycache__/__init__.cpython-313.pyc b/utils/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..13523488577db075d7afa78dc9c09370f676db33 Binary files /dev/null and b/utils/__pycache__/__init__.cpython-313.pyc differ diff --git a/utils/__pycache__/exception_handlers.cpython-313.pyc b/utils/__pycache__/exception_handlers.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7943014872e16d3f5248c332f418cb059bdec65c Binary files /dev/null and b/utils/__pycache__/exception_handlers.cpython-313.pyc differ diff --git a/utils/__pycache__/logging.cpython-313.pyc b/utils/__pycache__/logging.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f5fdf59d29f0a43b4bc7d708983243878ba8818a Binary files /dev/null and b/utils/__pycache__/logging.cpython-313.pyc differ diff --git a/utils/exception_handlers.py b/utils/exception_handlers.py new file mode 100644 index 0000000000000000000000000000000000000000..694d7f05dc0ddf2394834277027b3d62772d5196 --- /dev/null +++ b/utils/exception_handlers.py @@ -0,0 +1,81 @@ +from fastapi import Request, HTTPException +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException +from .logging import get_logger +from typing import Union + +logger = get_logger(__name__) + +async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: + """ + Handle HTTP exceptions globally. + + Args: + request: FastAPI request object + exc: HTTP exception + + Returns: + JSONResponse with error details + """ + logger.warning(f"HTTP Exception: {exc.status_code} - {exc.detail} - Path: {request.url.path}") + + return JSONResponse( + status_code=exc.status_code, + content={ + "error": { + "type": "http_exception", + "status_code": exc.status_code, + "message": str(exc.detail) + } + } + ) + +async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: + """ + Handle request validation exceptions globally. + + Args: + request: FastAPI request object + exc: Request validation exception + + Returns: + JSONResponse with validation error details + """ + logger.warning(f"Validation Exception: Path: {request.url.path}, Errors: {exc.errors()}") + + return JSONResponse( + status_code=422, + content={ + "error": { + "type": "validation_error", + "status_code": 422, + "message": "Validation failed", + "details": exc.errors() + } + } + ) + +async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """ + Handle general exceptions globally. + + Args: + request: FastAPI request object + exc: General exception + + Returns: + JSONResponse with error details + """ + logger.error(f"General Exception: {str(exc)} - Path: {request.url.path}", exc_info=True) + + return JSONResponse( + status_code=500, + content={ + "error": { + "type": "internal_error", + "status_code": 500, + "message": "Internal server error" + } + } + ) \ No newline at end of file diff --git a/utils/logging.py b/utils/logging.py new file mode 100644 index 0000000000000000000000000000000000000000..863f51953462f9764a8dc6770666b864080eadb7 --- /dev/null +++ b/utils/logging.py @@ -0,0 +1,56 @@ +import logging +import sys +from typing import Optional +from config.settings import settings + +def setup_logging( + log_level: Optional[str] = None, + log_format: Optional[str] = None +) -> logging.Logger: + """ + Set up logging configuration for the application. + + Args: + log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + log_format: Custom log format string + + Returns: + Configured logger instance + """ + # Determine log level from settings or use provided value + level = log_level or ("DEBUG" if settings.debug else "INFO") + level = getattr(logging, level.upper()) + + # Default log format + if log_format is None: + log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + + # Configure the root logger + logging.basicConfig( + level=level, + format=log_format, + handlers=[ + logging.StreamHandler(sys.stdout), # Log to stdout + ] + ) + + # Create and return a logger for the application + logger = logging.getLogger("todo_api") + logger.setLevel(level) + + return logger + +def get_logger(name: str) -> logging.Logger: + """ + Get a named logger instance. + + Args: + name: Name for the logger + + Returns: + Logger instance with the specified name + """ + return logging.getLogger(f"todo_api.{name}") + +# Create a default logger for the application +logger = setup_logging() \ No newline at end of file diff --git a/utils/validators.py b/utils/validators.py new file mode 100644 index 0000000000000000000000000000000000000000..70a4f1c8c3cd5108e30b59a3dbd20371de77301c --- /dev/null +++ b/utils/validators.py @@ -0,0 +1,140 @@ +from typing import Any, Optional +from pydantic import BaseModel, field_validator, ValidationError +import re + +def validate_email(email: str) -> str: + """ + Validate email format using regex. + + Args: + email: Email string to validate + + Returns: + Validated email string + + Raises: + ValueError: If email format is invalid + """ + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + if not re.match(pattern, email): + raise ValueError(f"Invalid email format: {email}") + return email.lower().strip() + +def validate_title(title: str) -> str: + """ + Validate task title - must be 1-255 characters and not just whitespace. + + Args: + title: Title string to validate + + Returns: + Validated title string + + Raises: + ValueError: If title is invalid + """ + if not title or not title.strip(): + raise ValueError("Title cannot be empty or just whitespace") + + title = title.strip() + if len(title) > 255: + raise ValueError(f"Title must be 255 characters or less, got {len(title)}") + + return title + +def validate_description(description: Optional[str]) -> Optional[str]: + """ + Validate task description - if provided, must be 1-1000 characters. + + Args: + description: Description string to validate (can be None) + + Returns: + Validated description string or None + + Raises: + ValueError: If description is invalid + """ + if description is None: + return None + + description = description.strip() + if len(description) > 1000: + raise ValueError(f"Description must be 1000 characters or less, got {len(description)}") + + return description + +def validate_user_id(user_id: int) -> int: + """ + Validate user ID - must be a positive integer. + + Args: + user_id: User ID to validate + + Returns: + Validated user ID + + Raises: + ValueError: If user ID is invalid + """ + if not isinstance(user_id, int) or user_id <= 0: + raise ValueError(f"User ID must be a positive integer, got {user_id}") + return user_id + +def validate_task_id(task_id: int) -> int: + """ + Validate task ID - must be a positive integer. + + Args: + task_id: Task ID to validate + + Returns: + Validated task ID + + Raises: + ValueError: If task ID is invalid + """ + if not isinstance(task_id, int) or task_id <= 0: + raise ValueError(f"Task ID must be a positive integer, got {task_id}") + return task_id + +class TaskValidator(BaseModel): + """ + Pydantic model for validating task data. + """ + title: str + description: Optional[str] = None + + @field_validator('title') + @classmethod + def validate_title_field(cls, v: str) -> str: + return validate_title(v) + + @field_validator('description') + @classmethod + def validate_description_field(cls, v: Optional[str]) -> Optional[str]: + return validate_description(v) + +class UserValidator(BaseModel): + """ + Pydantic model for validating user data. + """ + email: str + name: str + + @field_validator('email') + @classmethod + def validate_email_field(cls, v: str) -> str: + return validate_email(v) + + @field_validator('name') + @classmethod + def validate_name_field(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("Name cannot be empty or just whitespace") + + name = v.strip() + if len(name) > 255: + raise ValueError(f"Name must be 255 characters or less, got {len(name)}") + + return name \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..1300512562367610d352431e72ec96ec5cb91e43 --- /dev/null +++ b/uv.lock @@ -0,0 +1,745 @@ +version = 1 +revision = 2 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "backend" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "sqlmodel" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.metadata] +requires-dist = [ + { name = "asyncpg", specifier = ">=0.30.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx", specifier = ">=0.27.2" }, + { name = "pydantic", specifier = ">=2.9.2" }, + { name = "pydantic-settings", specifier = ">=2.6.1" }, + { name = "pyjwt", specifier = ">=2.9.0" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-asyncio", specifier = ">=0.23.7" }, + { name = "python-dotenv", specifier = ">=1.0.1" }, + { name = "python-multipart", specifier = ">=0.0.12" }, + { name = "sqlmodel", specifier = ">=0.0.22" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.128.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, + { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, + { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, + { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, + { url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" }, + { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, + { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, + { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, + { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, + { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" }, + { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f", size = 3333760, upload-time = "2025-12-09T22:11:02.66Z" }, + { url = "https://files.pythonhosted.org/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d", size = 3348268, upload-time = "2025-12-09T22:13:49.054Z" }, + { url = "https://files.pythonhosted.org/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4", size = 3278144, upload-time = "2025-12-09T22:11:04.14Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/310ecb5657221f3e1bd5288ed83aa554923fb5da48d760a9f7622afeb065/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6", size = 3313907, upload-time = "2025-12-09T22:13:50.598Z" }, + { url = "https://files.pythonhosted.org/packages/5c/39/69c0b4051079addd57c84a5bfb34920d87456dd4c90cf7ee0df6efafc8ff/sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953", size = 2112182, upload-time = "2025-12-09T21:39:30.824Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4e/510db49dd89fc3a6e994bee51848c94c48c4a00dc905e8d0133c251f41a7/sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1", size = 2139200, upload-time = "2025-12-09T21:39:32.321Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" }, + { url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" }, + { url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" }, + { url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" }, + { url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" }, + { url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" }, + { url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" }, + { url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" }, + { url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, +] + +[[package]] +name = "sqlmodel" +version = "0.0.30" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/bc/162873c9b2466bb6bda1531b6023b5d8ecda4daffaaae54e6fec53e2c0a7/sqlmodel-0.0.30.tar.gz", hash = "sha256:076076976aac683bae3e10c791d9e2ec5fead57ab25f7e476dea36cdfe9505e1", size = 97581, upload-time = "2025-12-26T11:45:55.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/f3/247bad31f13066a8186f2d4d1a43c75a441ed9f889d629d69d9820116dfc/sqlmodel-0.0.30-py3-none-any.whl", hash = "sha256:1b7f992170ff3145d98a72d41271335c8bb51d579c7254a1626f82bde4ec64ed", size = 29362, upload-time = "2025-12-26T11:45:53.298Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/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", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/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", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +]