diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4283346 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +repos: + - repo: local + hooks: + - id: ruff-format + name: ruff format --check + entry: uv run ruff format --check + language: system + types: [python] + pass_filenames: false + + - id: ruff-check + name: ruff check + entry: uv run ruff check + language: system + types: [python] + pass_filenames: false + + - id: ty-check + name: ty check + entry: uv run ty check . + language: system + types: [python] + pass_filenames: false diff --git a/exceptions/exceptions.py b/exceptions/exceptions.py index 0172b88..975444c 100644 --- a/exceptions/exceptions.py +++ b/exceptions/exceptions.py @@ -11,4 +11,5 @@ def __init__(self, user: User, access_token: str, refresh_token: str): # Define custom exception for email sending failure class EmailSendFailedError(Exception): """Custom exception for email sending failures.""" - pass \ No newline at end of file + + pass diff --git a/exceptions/http_exceptions.py b/exceptions/http_exceptions.py index a75f205..6f4dcf1 100644 --- a/exceptions/http_exceptions.py +++ b/exceptions/http_exceptions.py @@ -1,104 +1,78 @@ from fastapi import HTTPException, status + class RateLimitError(HTTPException): """Raised when a client exceeds the allowed request rate.""" + def __init__(self, retry_after: int = 60): self.retry_after = retry_after super().__init__( - status_code=429, - detail="Too many attempts. Please try again later." + status_code=429, detail="Too many attempts. Please try again later." ) class EmailAlreadyRegisteredError(HTTPException): def __init__(self): - super().__init__( - status_code=409, - detail="This email is already registered" - ) + super().__init__(status_code=409, detail="This email is already registered") class CredentialsError(HTTPException): def __init__(self, message: str = "Invalid credentials"): - super().__init__( - status_code=401, - detail=message - ) + super().__init__(status_code=401, detail=message) class AuthenticationError(HTTPException): def __init__(self): super().__init__( - status_code=status.HTTP_303_SEE_OTHER, - headers={"Location": "/login"} + status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/login"} ) class AlreadyAuthenticatedError(HTTPException): """Raised when an authenticated user tries to access a page meant for unauthenticated users.""" + def __init__(self): super().__init__( - status_code=status.HTTP_303_SEE_OTHER, - headers={"Location": "/dashboard/"} + status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/dashboard/"} ) class PasswordValidationError(HTTPException): def __init__(self, field: str, message: str): - super().__init__( - status_code=422, - detail={ - "field": field, - "message": message - } - ) + super().__init__(status_code=422, detail={"field": field, "message": message}) class InsufficientPermissionsError(HTTPException): def __init__(self): super().__init__( - status_code=403, - detail="You don't have permission to perform this action" + status_code=403, detail="You don't have permission to perform this action" ) class OrganizationSetupError(HTTPException): def __init__(self, message: str = "Organization setup failed"): - super().__init__( - status_code=500, - detail=message - ) + super().__init__(status_code=500, detail=message) class OrganizationNameTakenError(HTTPException): def __init__(self): - super().__init__( - status_code=400, - detail="Organization name already taken" - ) + super().__init__(status_code=400, detail="Organization name already taken") class OrganizationNotFoundError(HTTPException): def __init__(self): - super().__init__( - status_code=404, - detail="Organization not found" - ) + super().__init__(status_code=404, detail="Organization not found") class UserNotFoundError(HTTPException): def __init__(self): - super().__init__( - status_code=404, - detail="User not found" - ) + super().__init__(status_code=404, detail="User not found") class UserAlreadyMemberError(HTTPException): def __init__(self): super().__init__( - status_code=400, - detail="User is already a member of this organization" + status_code=400, detail="User is already a member of this organization" ) @@ -106,10 +80,7 @@ class InvalidPermissionError(HTTPException): """Raised when a user attempts to assign an invalid permission to a role""" def __init__(self, permission: str): - super().__init__( - status_code=400, - detail=f"Invalid permission: {permission}" - ) + super().__init__(status_code=400, detail=f"Invalid permission: {permission}") class RoleAlreadyExistsError(HTTPException): @@ -132,29 +103,26 @@ class RoleHasUsersError(HTTPException): def __init__(self): super().__init__( status_code=400, - detail="Role cannot be deleted until users with that role are reassigned" + detail="Role cannot be deleted until users with that role are reassigned", ) class CannotModifyDefaultRoleError(HTTPException): """Raised when attempting to modify or delete a default system role.""" + def __init__(self, action: str = "modify"): super().__init__( - status_code=403, - detail=f"Default system roles cannot be {action}d." + status_code=403, detail=f"Default system roles cannot be {action}d." ) class DataIntegrityError(HTTPException): - def __init__( - self, - resource: str = "Database resource" - ): + def __init__(self, resource: str = "Database resource"): super().__init__( status_code=500, detail=( f"{resource} is in a broken state; please contact a system administrator" - ) + ), ) @@ -167,21 +135,23 @@ def __init__(self, message: str = "Invalid image file"): # --- Invitation-specific Errors --- + class UserIsAlreadyMemberError(HTTPException): """Raised when trying to invite a user who is already a member of the organization.""" + def __init__(self): super().__init__( - status_code=409, - detail="This user is already a member of the organization." + status_code=409, detail="This user is already a member of the organization." ) class ActiveInvitationExistsError(HTTPException): """Raised when trying to invite a user for whom an active invitation already exists.""" + def __init__(self): super().__init__( status_code=409, - detail="An active invitation already exists for this email address in this organization." + detail="An active invitation already exists for this email address in this organization.", ) @@ -189,71 +159,71 @@ class InvalidRoleForOrganizationError(HTTPException): """Raised when a role provided does not belong to the target organization. Note: If the role ID simply doesn't exist, a standard 404 RoleNotFoundError should be raised. """ + def __init__(self): super().__init__( status_code=400, - detail="The selected role does not belong to this organization." + detail="The selected role does not belong to this organization.", ) class InvitationEmailSendError(HTTPException): """Raised when the invitation email fails to send.""" + def __init__(self): super().__init__( - status_code=500, # Internal Server Error seems appropriate - detail="Failed to send invitation email. Please try again later or contact support." + status_code=500, # Internal Server Error seems appropriate + detail="Failed to send invitation email. Please try again later or contact support.", ) class InvalidInvitationTokenError(HTTPException): """Raised when an invitation token is invalid, expired, or not found.""" + def __init__(self): - super().__init__( - status_code=404, - detail="Invitation not found or expired" - ) + super().__init__(status_code=404, detail="Invitation not found or expired") class InvitationEmailMismatchError(HTTPException): """Raised when a user attempts to accept an invitation sent to a different email address.""" + def __init__(self): super().__init__( status_code=403, - detail="This invitation was sent to a different email address" + detail="This invitation was sent to a different email address", ) class MaxEmailsReachedError(HTTPException): """Raised when an account already has the maximum number of email addresses.""" + def __init__(self): super().__init__( - status_code=400, - detail="Maximum number of email addresses reached" + status_code=400, detail="Maximum number of email addresses reached" ) class EmailNotVerifiedError(HTTPException): """Raised when attempting to promote an unverified email address.""" + def __init__(self): - super().__init__( - status_code=400, - detail="Email address is not verified" - ) + super().__init__(status_code=400, detail="Email address is not verified") class CannotRemovePrimaryEmailError(HTTPException): """Raised when attempting to remove the primary email address.""" + def __init__(self): - super().__init__( - status_code=400, - detail="Cannot remove primary email address" - ) + super().__init__(status_code=400, detail="Cannot remove primary email address") class InvitationProcessingError(HTTPException): """Raised when an error occurs during the processing of a valid invitation.""" - def __init__(self, detail: str = "Failed to process invitation. Please try again later."): + + def __init__( + self, detail: str = "Failed to process invitation. Please try again later." + ): super().__init__( - status_code=500, # Internal Server Error - detail=detail - ) \ No newline at end of file + status_code=500, # Internal Server Error + detail=detail, + ) diff --git a/main.py b/main.py index 3741b53..06a2921 100644 --- a/main.py +++ b/main.py @@ -7,23 +7,34 @@ from fastapi.templating import Jinja2Templates from fastapi.exceptions import RequestValidationError from starlette.exceptions import HTTPException as StarletteHTTPException -from routers.core import account, dashboard, organization, role, user, static_pages, invitation +from routers.core import ( + account, + dashboard, + organization, + role, + user, + static_pages, + invitation, +) from utils.core.dependencies import ( get_user_from_request, - require_unauthenticated_client + require_unauthenticated_client, ) from utils.core.auth import COOKIE_SECURE -from utils.core.htmx import is_htmx_request, toast_response, get_flash_cookie, FLASH_COOKIE_NAME +from utils.core.htmx import ( + is_htmx_request, + toast_response, + get_flash_cookie, + FLASH_COOKIE_NAME, +) from exceptions.http_exceptions import ( AlreadyAuthenticatedError, AuthenticationError, PasswordValidationError, CredentialsError, - RateLimitError -) -from exceptions.exceptions import ( - NeedsNewTokens + RateLimitError, ) +from exceptions.exceptions import NeedsNewTokens from utils.core.db import set_up_db logger = logging.getLogger("uvicorn.error") @@ -62,7 +73,6 @@ async def flash_cookie_middleware(request: Request, call_next): return response - # --- Include Routers --- @@ -86,21 +96,21 @@ async def authentication_error_handler(request: Request, exc: AuthenticationErro response.headers["HX-Redirect"] = str(request.url_for("read_login")) return response return RedirectResponse( - url=app.url_path_for("read_login"), - status_code=status.HTTP_303_SEE_OTHER + url=app.url_path_for("read_login"), status_code=status.HTTP_303_SEE_OTHER ) # Handle AlreadyAuthenticatedError by redirecting to dashboard @app.exception_handler(AlreadyAuthenticatedError) -async def already_authenticated_error_handler(request: Request, exc: AlreadyAuthenticatedError): +async def already_authenticated_error_handler( + request: Request, exc: AlreadyAuthenticatedError +): if is_htmx_request(request): response = Response(status_code=200) response.headers["HX-Redirect"] = str(request.url_for("read_dashboard")) return response return RedirectResponse( - url=app.url_path_for("read_dashboard"), - status_code=status.HTTP_302_FOUND + url=app.url_path_for("read_dashboard"), status_code=status.HTTP_302_FOUND ) @@ -109,8 +119,12 @@ async def already_authenticated_error_handler(request: Request, exc: AlreadyAuth async def rate_limit_error_handler(request: Request, exc: RateLimitError): if is_htmx_request(request): return toast_response( - request, templates, exc.detail, level="danger", - status_code=429, headers={"Retry-After": str(exc.retry_after)}, + request, + templates, + exc.detail, + level="danger", + status_code=429, + headers={"Retry-After": str(exc.retry_after)}, ) user = await get_user_from_request(request) response = templates.TemplateResponse( @@ -128,14 +142,22 @@ async def rate_limit_error_handler(request: Request, exc: RateLimitError): async def credentials_exception_handler(request: Request, exc: CredentialsError): if is_htmx_request(request): return toast_response( - request, templates, exc.detail or "Invalid email or password.", - level="danger", status_code=401, + request, + templates, + exc.detail or "Invalid email or password.", + level="danger", + status_code=401, ) user = await get_user_from_request(request) return templates.TemplateResponse( request, "errors/error.html", - {"status_code": exc.status_code, "detail": exc.detail, "errors": None, "user": user}, + { + "status_code": exc.status_code, + "detail": exc.detail, + "errors": None, + "user": user, + }, status_code=exc.status_code, ) @@ -146,20 +168,21 @@ async def needs_new_tokens_handler(request: Request, exc: NeedsNewTokens): # Preserve query string so GET routes with query params work after token refresh redirect_url = str(request.url) response = RedirectResponse( - url=redirect_url, status_code=status.HTTP_307_TEMPORARY_REDIRECT) + url=redirect_url, status_code=status.HTTP_307_TEMPORARY_REDIRECT + ) response.set_cookie( key="access_token", value=exc.access_token, httponly=True, secure=COOKIE_SECURE, - samesite="strict" + samesite="strict", ) response.set_cookie( key="refresh_token", value=exc.refresh_token, httponly=True, secure=COOKIE_SECURE, - samesite="strict" + samesite="strict", ) return response @@ -167,8 +190,7 @@ async def needs_new_tokens_handler(request: Request, exc: NeedsNewTokens): # Handle PasswordValidationError by rendering the validation_error page @app.exception_handler(PasswordValidationError) async def password_validation_exception_handler( - request: Request, - exc: PasswordValidationError + request: Request, exc: PasswordValidationError ) -> Response: if is_htmx_request(request): detail = exc.detail @@ -177,7 +199,11 @@ async def password_validation_exception_handler( else: message = str(detail) return toast_response( - request, templates, message, level="danger", status_code=422, + request, + templates, + message, + level="danger", + status_code=422, ) detail = exc.detail if isinstance(detail, dict): @@ -194,7 +220,7 @@ async def password_validation_exception_handler( "status_code": 422, "detail": None, "errors": {field.replace("_", " ").title(): message}, - "user": user + "user": user, }, status_code=422, ) @@ -202,10 +228,7 @@ async def password_validation_exception_handler( # Handle RequestValidationError by rendering the error page @app.exception_handler(RequestValidationError) -async def validation_exception_handler( - request: Request, - exc: RequestValidationError -): +async def validation_exception_handler(request: Request, exc: RequestValidationError): errors = {} # Map error types to user-friendly message templates @@ -214,7 +237,7 @@ async def validation_exception_handler( "string_too_short": "this field is required", "missing": "this field is required", "string_pattern_mismatch": "this field cannot be empty or contain only whitespace", - "enum": "invalid value" + "enum": "invalid value", } for error in exc.errors(): @@ -244,23 +267,24 @@ async def validation_exception_handler( errors[display_name] = message_template if is_htmx_request(request): - message = "; ".join( - f"{k}: {v}" for k, v in errors.items() - ) if errors else "Validation error" + message = ( + "; ".join(f"{k}: {v}" for k, v in errors.items()) + if errors + else "Validation error" + ) return toast_response( - request, templates, message, level="danger", status_code=422, + request, + templates, + message, + level="danger", + status_code=422, ) user = await get_user_from_request(request) return templates.TemplateResponse( request, "errors/error.html", - { - "status_code": 422, - "detail": None, - "errors": errors, - "user": user - }, + {"status_code": 422, "detail": None, "errors": errors, "user": user}, status_code=422, ) @@ -271,14 +295,22 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException): if is_htmx_request(request): detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail) return toast_response( - request, templates, detail, level="danger", + request, + templates, + detail, + level="danger", status_code=exc.status_code, ) user = await get_user_from_request(request) return templates.TemplateResponse( request, "errors/error.html", - {"status_code": exc.status_code, "detail": exc.detail, "errors": None, "user": user}, + { + "status_code": exc.status_code, + "detail": exc.detail, + "errors": None, + "user": user, + }, status_code=exc.status_code, ) @@ -291,8 +323,11 @@ async def general_exception_handler(request: Request, exc: Exception): if is_htmx_request(request): return toast_response( - request, templates, "Internal Server Error", - level="danger", status_code=500, + request, + templates, + "Internal Server Error", + level="danger", + status_code=500, ) user = await get_user_from_request(request) @@ -304,7 +339,7 @@ async def general_exception_handler(request: Request, exc: Exception): "status_code": 500, "detail": "Internal Server Error", "errors": None, - "user": user + "user": user, }, status_code=500, ) @@ -315,14 +350,9 @@ async def general_exception_handler(request: Request, exc: Exception): @app.get("/") async def read_home( - request: Request, - _: None = Depends(require_unauthenticated_client) + request: Request, _: None = Depends(require_unauthenticated_client) ): - return templates.TemplateResponse( - request, - "index.html", - {"user": None} - ) + return templates.TemplateResponse(request, "index.html", {"user": None}) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index b66cf30..741d354 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dev = [ "ruff>=0.15.5", "pytest-jinja-check[fastapi]>=1.0.2", "pytest-playwright>=0.7.2", + "pre-commit>=4.6.0", ] [tool.ty.rules] diff --git a/routers/app/__init__.py b/routers/app/__init__.py index 4db90c6..e687f05 100644 --- a/routers/app/__init__.py +++ b/routers/app/__init__.py @@ -1 +1 @@ -"""This folder is where you would define application-specific, auth-protected routers.""" \ No newline at end of file +"""This folder is where you would define application-specific, auth-protected routers.""" diff --git a/routers/core/account.py b/routers/core/account.py index 394bfbf..c9f9934 100644 --- a/routers/core/account.py +++ b/routers/core/account.py @@ -8,7 +8,13 @@ from starlette.datastructures import URLPath from pydantic import EmailStr from sqlmodel import Session, select -from utils.core.models import User, DataIntegrityError, Account, AccountEmail, Invitation +from utils.core.models import ( + User, + DataIntegrityError, + Account, + AccountEmail, + Invitation, +) from utils.core.dependencies import get_session from utils.core.models import RefreshToken from utils.core.auth import ( @@ -38,7 +44,7 @@ get_account_from_recovery_token, get_account_from_credentials, require_unauthenticated_client, - get_verified_account + get_verified_account, ) from exceptions.http_exceptions import ( EmailAlreadyRegisteredError, @@ -49,7 +55,7 @@ PasswordValidationError, InvalidInvitationTokenError, InvitationEmailMismatchError, - InvitationProcessingError + InvitationProcessingError, ) from routers.core.dashboard import router as dashboard_router from routers.core.user import router as user_router @@ -64,6 +70,7 @@ login_email_limiter, ) from utils.core.htmx import is_htmx_request, toast_response, set_flash_cookie + logger = getLogger("uvicorn.error") router = APIRouter(prefix="/account", tags=["account"]) @@ -75,18 +82,20 @@ def validate_password_strength_and_match( password: str = Form(..., title="Password", description="Account password"), - confirm_password: str = Form(..., title="Confirm password", description="Re-enter password to confirm") + confirm_password: str = Form( + ..., title="Confirm password", description="Re-enter password to confirm" + ), ) -> str: """ Validates password strength and confirms passwords match. - + Args: password: Password from form confirm_password: Confirmation password from form - + Raises: PasswordValidationError: If password is weak or passwords don't match - + Returns: str: The validated password """ @@ -94,16 +103,15 @@ def validate_password_strength_and_match( if not COMPILED_PASSWORD_PATTERN.match(password): raise PasswordValidationError( field="password", - message="Password must contain at least 8 characters, including one uppercase letter, one lowercase letter, one number, and one special character" + message="Password must contain at least 8 characters, including one uppercase letter, one lowercase letter, one number, and one special character", ) - + # Validate passwords match if password != confirm_password: raise PasswordValidationError( - field="confirm_password", - message="The passwords you entered do not match" + field="confirm_password", message="The passwords you entered do not match" ) - + return password @@ -140,7 +148,7 @@ def logout( async def read_login( request: Request, _: None = Depends(require_unauthenticated_client), - invitation_token: Optional[str] = Query(None) + invitation_token: Optional[str] = Query(None), ): """ Render login page or redirect to dashboard if already logged in. @@ -148,10 +156,7 @@ async def read_login( return templates.TemplateResponse( request, "account/login.html", - { - "user": None, - "invitation_token": invitation_token - } + {"user": None, "invitation_token": invitation_token}, ) @@ -160,7 +165,7 @@ async def read_register( request: Request, _: None = Depends(require_unauthenticated_client), email: Optional[EmailStr] = Query(None), - invitation_token: Optional[str] = Query(None) + invitation_token: Optional[str] = Query(None), ): """ Render registration page or redirect to dashboard if already logged in. @@ -172,8 +177,8 @@ async def read_register( "user": None, "password_pattern": HTML_PASSWORD_PATTERN, "email": email, - "invitation_token": invitation_token - } + "invitation_token": invitation_token, + }, ) @@ -189,7 +194,7 @@ async def read_forgot_password( return templates.TemplateResponse( request, "account/forgot_password.html", - {"user": None, "show_form": show_form == "true"} + {"user": None, "show_form": show_form == "true"}, ) @@ -199,7 +204,7 @@ async def read_reset_password( email: str, token: str, user: Optional[User] = Depends(get_optional_user), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ): """ Render reset password page after validating token. @@ -213,14 +218,19 @@ async def read_reset_password( return templates.TemplateResponse( request, "account/reset_password.html", - {"user": user, "email": email, "token": token, "password_pattern": HTML_PASSWORD_PATTERN} + { + "user": user, + "email": email, + "token": token, + "password_pattern": HTML_PASSWORD_PATTERN, + }, ) @router.post("/delete", response_class=RedirectResponse) async def delete_account( account: Account = Depends(get_verified_account), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ): """ Delete a user account after verifying credentials. @@ -238,19 +248,32 @@ async def delete_account( async def register( request: Request, _ip_check: None = Depends(check_register_ip_rate_limit), - name: str = Form(..., min_length=1, strip_whitespace=True, title="Name", description="Your full name"), - email: EmailStr = Form(..., title="Email", description="Email address for the new account"), + name: str = Form( + ..., + min_length=1, + strip_whitespace=True, + title="Name", + description="Your full name", + ), + email: EmailStr = Form( + ..., title="Email", description="Email address for the new account" + ), session: Session = Depends(get_session), _: None = Depends(validate_password_strength_and_match), password: str = Form(..., title="Password", description="Account password"), - invitation_token: Optional[str] = Form(None, title="Invitation token", description="Optional invitation token to join an organization") + invitation_token: Optional[str] = Form( + None, + title="Invitation token", + description="Optional invitation token to join an organization", + ), ) -> Response: """ Register a new user account, optionally processing an invitation. """ # Check if the email is already registered - existing_account: Optional[Account] = session.exec(select(Account).where( - Account.email == email)).one_or_none() + existing_account: Optional[Account] = session.exec( + select(Account).where(Account.email == email) + ).one_or_none() if existing_account: raise EmailAlreadyRegisteredError() @@ -261,19 +284,22 @@ async def register( # Create the account and user instances (don't commit yet) account = Account(email=email, hashed_password=hashed_password) session.add(account) - session.flush() # Flush here to get account.id before creating User + session.flush() # Flush here to get account.id before creating User # Ensure account has an ID after flush if not account.id: - logger.error(f"Account ID not generated after flush for email {email}. Aborting registration.") - session.rollback() # Rollback the account add + logger.error( + f"Account ID not generated after flush for email {email}. Aborting registration." + ) + session.rollback() # Rollback the account add raise DataIntegrityError(resource="Account ID generation") - new_user = User(name=name, account_id=account.id) # Use account.id + new_user = User(name=name, account_id=account.id) # Use account.id session.add(new_user) # Create the primary AccountEmail entry from datetime import datetime, UTC + account_email = AccountEmail( account_id=account.id, email=email, @@ -288,13 +314,17 @@ async def register( # Process invitation if token is provided (BEFORE final commit) if invitation_token: - logger.info(f"Registration attempt with invitation token: {invitation_token} for email {email}") + logger.info( + f"Registration attempt with invitation token: {invitation_token} for email {email}" + ) # Fetch the invitation statement = select(Invitation).where(Invitation.token == invitation_token) invitation = session.exec(statement).first() if not invitation or not invitation.is_active(): - logger.warning(f"Invalid or inactive invitation token provided during registration: {invitation_token}") + logger.warning( + f"Invalid or inactive invitation token provided during registration: {invitation_token}" + ) # Consider raising a more generic error to avoid exposing token validity raise InvalidInvitationTokenError() @@ -309,27 +339,38 @@ async def register( # Process the invitation (adds changes to the session) try: - logger.info(f"Processing invitation {invitation.id} for new user {new_user.name} ({email}) during registration.") + logger.info( + f"Processing invitation {invitation.id} for new user {new_user.name} ({email}) during registration." + ) process_invitation(invitation, new_user, session) # Set redirect to the organization page - redirect_url = org_router.url_path_for("read_organization", org_id=invitation.organization_id) - logger.info(f"Redirecting new user {new_user.name} to organization {invitation.organization_id} after accepting invitation {invitation.id}.") + redirect_url = org_router.url_path_for( + "read_organization", org_id=invitation.organization_id + ) + logger.info( + f"Redirecting new user {new_user.name} to organization {invitation.organization_id} after accepting invitation {invitation.id}." + ) except Exception as e: - logger.error( - f"Error processing invitation {invitation.id} for new user {new_user.name} ({email}) during registration: {e}", - exc_info=True - ) - session.rollback() - raise InvitationProcessingError() + logger.error( + f"Error processing invitation {invitation.id} for new user {new_user.name} ({email}) during registration: {e}", + exc_info=True, + ) + session.rollback() + raise InvitationProcessingError() else: - logger.info(f"Standard registration for email {email}. Redirecting to dashboard.") + logger.info( + f"Standard registration for email {email}. Redirecting to dashboard." + ) # Commit all changes (Account, User, potentially Invitation) try: session.commit() except Exception as e: - logger.error(f"Error committing transaction during registration for {email}: {e}", exc_info=True) + logger.error( + f"Error committing transaction during registration for {email}: {e}", + exc_info=True, + ) session.rollback() # Use DataIntegrityError for commit failure raise DataIntegrityError(resource="Account/User registration") @@ -355,14 +396,14 @@ async def register( value=access_token, httponly=True, secure=COOKIE_SECURE, - samesite="strict" + samesite="strict", ) response.set_cookie( key="refresh_token", value=refresh_token, httponly=True, secure=COOKIE_SECURE, - samesite="strict" + samesite="strict", ) return response @@ -373,8 +414,14 @@ async def login( request: Request, _ip_check: None = Depends(check_login_ip_rate_limit), _email_check: EmailStr = Depends(check_login_email_rate_limit), - account_and_session: Tuple[Account, Session] = Depends(get_account_from_credentials), - invitation_token: Optional[str] = Form(None, title="Invitation token", description="Optional invitation token to join an organization after login") + account_and_session: Tuple[Account, Session] = Depends( + get_account_from_credentials + ), + invitation_token: Optional[str] = Form( + None, + title="Invitation token", + description="Optional invitation token to join an organization after login", + ), ) -> Response: """ Log in a user with valid credentials and process invitation if token is provided. @@ -389,13 +436,17 @@ async def login( redirect_url = dashboard_router.url_path_for("read_dashboard") if invitation_token: - logger.info(f"Login attempt with invitation token: {invitation_token} for account {account.email}") + logger.info( + f"Login attempt with invitation token: {invitation_token} for account {account.email}" + ) # Fetch the invitation statement = select(Invitation).where(Invitation.token == invitation_token) invitation = session.exec(statement).first() if not invitation or not invitation.is_active(): - logger.warning(f"Invalid or inactive invitation token provided during login: {invitation_token}") + logger.warning( + f"Invalid or inactive invitation token provided during login: {invitation_token}" + ) raise InvalidInvitationTokenError() # Verify email matches (check primary and any verified secondary emails) @@ -417,39 +468,46 @@ async def login( logger.debug(f"Refreshing user relationship for account {account.id}") session.refresh(account, attribute_names=["user"]) if not account.user: - # This should not happen if the account has a valid user relationship - logger.error(f"Failed to load user for account {account.id} during invitation processing.") - raise DataIntegrityError(resource="User relation") + # This should not happen if the account has a valid user relationship + logger.error( + f"Failed to load user for account {account.id} during invitation processing." + ) + raise DataIntegrityError(resource="User relation") # Process the invitation try: if account.user and account.user.id: - logger.info(f"Processing invitation {invitation.id} for user {account.user.id} during login.") + logger.info( + f"Processing invitation {invitation.id} for user {account.user.id} during login." + ) process_invitation(invitation, account.user, session) session.commit() # Set redirect to the organization page - redirect_url = org_router.url_path_for("read_organization", org_id=invitation.organization_id) - logger.info(f"Redirecting user {account.user.id} to organization {invitation.organization_id} after accepting invitation {invitation.id}.") + redirect_url = org_router.url_path_for( + "read_organization", org_id=invitation.organization_id + ) + logger.info( + f"Redirecting user {account.user.id} to organization {invitation.organization_id} after accepting invitation {invitation.id}." + ) else: logger.error("User has no ID during invitation processing.") raise DataIntegrityError(resource="User ID") except Exception as e: logger.error( - f"Error processing invitation during login: {e}", - exc_info=True + f"Error processing invitation during login: {e}", exc_info=True ) session.rollback() # Raise the specific invitation processing error raise InvitationProcessingError() else: - logger.info(f"Standard login for account {account.email}. Redirecting to dashboard.") + logger.info( + f"Standard login for account {account.email}. Redirecting to dashboard." + ) # Create access token assert account.id is not None - access_token = create_access_token( - data={"sub": account.email, "fresh": True} - ) + access_token = create_access_token(data={"sub": account.email, "fresh": True}) refresh_token = create_tracked_refresh_token(account.id, account.email, session) session.commit() @@ -492,7 +550,9 @@ async def refresh_token( decoded_token = validate_token(refresh_token, token_type="refresh") if not decoded_token: - response = RedirectResponse(url=router.url_path_for("read_login"), status_code=303) + response = RedirectResponse( + url=router.url_path_for("read_login"), status_code=303 + ) response.delete_cookie("access_token") response.delete_cookie("refresh_token") return response @@ -500,20 +560,21 @@ async def refresh_token( # Validate JTI server-side jti = decoded_token.get("jti") if not jti: - response = RedirectResponse(url=router.url_path_for("read_login"), status_code=303) + response = RedirectResponse( + url=router.url_path_for("read_login"), status_code=303 + ) response.delete_cookie("access_token") response.delete_cookie("refresh_token") return response user_email = decoded_token.get("sub") - account = session.exec(select(Account).where( - Account.email == user_email)).one_or_none() + account = session.exec( + select(Account).where(Account.email == user_email) + ).one_or_none() if not account: return RedirectResponse(url=router.url_path_for("read_login"), status_code=303) - db_token = session.exec( - select(RefreshToken).where(RefreshToken.jti == jti) - ).first() + db_token = session.exec(select(RefreshToken).where(RefreshToken.jti == jti)).first() if not db_token or db_token.account_id != account.id: return RedirectResponse(url=router.url_path_for("read_login"), status_code=303) @@ -527,20 +588,22 @@ async def refresh_token( ) revoke_all_refresh_tokens(account.id, session) session.commit() - response = RedirectResponse(url=router.url_path_for("read_login"), status_code=303) + response = RedirectResponse( + url=router.url_path_for("read_login"), status_code=303 + ) response.delete_cookie("access_token") response.delete_cookie("refresh_token") return response # Revoke current token and issue new ones db_token.revoked = True - new_access_token = create_access_token( - data={"sub": account.email, "fresh": False} - ) + new_access_token = create_access_token(data={"sub": account.email, "fresh": False}) new_refresh_token = create_tracked_refresh_token(account.id, account.email, session) session.commit() - response = RedirectResponse(url=dashboard_router.url_path_for("read_dashboard"), status_code=303) + response = RedirectResponse( + url=dashboard_router.url_path_for("read_dashboard"), status_code=303 + ) response.set_cookie( key="access_token", value=new_access_token, @@ -565,14 +628,13 @@ async def forgot_password( request: Request, _ip_check: None = Depends(check_forgot_password_ip_rate_limit), email: EmailStr = Depends(check_forgot_password_email_rate_limit), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ): """ Send a password reset email to the user. """ # TODO: Make this a dependency? - account = session.exec(select(Account).where( - Account.email == email)).one_or_none() + account = session.exec(select(Account).where(Account.email == email)).one_or_none() if account: background_tasks.add_task(send_reset_email_task, email) @@ -587,8 +649,13 @@ async def forgot_password( response = Response(status_code=200) response.headers["HX-Redirect"] = f"{redirect_path}?show_form=false" else: - response = RedirectResponse(url=f"{redirect_path}?show_form=false", status_code=303) - set_flash_cookie(response, "If an account exists with this email, a password reset link will be sent.") + response = RedirectResponse( + url=f"{redirect_path}?show_form=false", status_code=303 + ) + set_flash_cookie( + response, + "If an account exists with this email, a password reset link will be sent.", + ) return response @@ -596,9 +663,11 @@ async def forgot_password( async def reset_password( request: Request, email: EmailStr = Form(..., title="Email", description="Account email address"), - token: str = Form(..., title="Reset token", description="Password reset token from email"), + token: str = Form( + ..., title="Reset token", description="Password reset token from email" + ), new_password: str = Depends(validate_password_strength_and_match), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ): """ Reset a user's password using a valid token. @@ -610,7 +679,9 @@ async def reset_password( ) if not authorized_account or not reset_token: - raise CredentialsError("Invalid or expired password reset token; please request a new one") + raise CredentialsError( + "Invalid or expired password reset token; please request a new one" + ) assert authorized_account.id is not None # Update password and mark token as used @@ -621,8 +692,12 @@ async def reset_password( session.refresh(authorized_account) # Auto-login: issue new auth cookies so the user doesn't have to re-enter credentials - access_token = create_access_token(data={"sub": authorized_account.email, "fresh": True}) - refresh_token = create_tracked_refresh_token(authorized_account.id, authorized_account.email, session) + access_token = create_access_token( + data={"sub": authorized_account.email, "fresh": True} + ) + refresh_token = create_tracked_refresh_token( + authorized_account.id, authorized_account.email, session + ) session.commit() redirect_url = str(dashboard_router.url_path_for("read_dashboard")) @@ -635,12 +710,18 @@ async def reset_password( response = RedirectResponse(url=redirect_url, status_code=303) response.set_cookie( - key="access_token", value=access_token, - httponly=True, secure=COOKIE_SECURE, samesite="strict", + key="access_token", + value=access_token, + httponly=True, + secure=COOKIE_SECURE, + samesite="strict", ) response.set_cookie( - key="refresh_token", value=refresh_token, - httponly=True, secure=COOKIE_SECURE, samesite="strict", + key="refresh_token", + value=refresh_token, + httponly=True, + secure=COOKIE_SECURE, + samesite="strict", ) set_flash_cookie(response, message) return response @@ -677,6 +758,7 @@ async def recover_account( # Restore the victim's email as primary from datetime import datetime as dt, UTC as utc_tz + restored_email = AccountEmail( account_id=account.id, email=recovery_token.email, @@ -694,6 +776,7 @@ async def recover_account( # Create a password reset token from utils.core.models import PasswordResetToken + reset_token = PasswordResetToken(account_id=account.id) session.add(reset_token) @@ -702,6 +785,7 @@ async def recover_account( # Redirect to password reset page from utils.core.auth import generate_password_reset_url + reset_url = generate_password_reset_url(recovery_token.email, reset_token.token) response = RedirectResponse(url=reset_url, status_code=303) set_flash_cookie(response, "Account recovered. Please set a new password.") @@ -714,7 +798,9 @@ async def recover_account( @router.post("/emails/add") async def add_email( request: Request, - new_email: EmailStr = Form(..., title="New email", description="New email address to add"), + new_email: EmailStr = Form( + ..., title="New email", description="New email address to add" + ), account: Account = Depends(get_authenticated_account), session: Session = Depends(get_session), ): @@ -730,9 +816,11 @@ async def add_email( raise EmailAlreadyRegisteredError() # Check account hasn't reached the limit - email_count = len(session.exec( - select(AccountEmail).where(AccountEmail.account_id == account.id) - ).all()) + email_count = len( + session.exec( + select(AccountEmail).where(AccountEmail.account_id == account.id) + ).all() + ) if email_count >= MAX_EMAILS_PER_ACCOUNT: raise MaxEmailsReachedError() @@ -740,11 +828,18 @@ async def add_email( # Send verification email (suppresses if unexpired token exists) sent = send_email_verification(account.id, new_email, session) - message = "Verification email sent. Check your inbox." if sent else "A verification email was already sent. Please check your inbox." + message = ( + "Verification email sent. Check your inbox." + if sent + else "A verification email was already sent. Please check your inbox." + ) if is_htmx_request(request): return toast_response( - request, templates, message, level="success", + request, + templates, + message, + level="success", headers={"HX-Trigger": "addEmailFormReset"}, ) profile_path: URLPath = user_router.url_path_for("read_profile") @@ -765,7 +860,9 @@ async def verify_email( from an email client (cross-site navigation), so samesite=strict auth cookies are never sent — even when the user has an active session. """ - account, verification_token = get_account_from_email_verification_token(token, session) + account, verification_token = get_account_from_email_verification_token( + token, session + ) if not account or not verification_token: raise CredentialsError(message="Invalid or expired verification token") @@ -781,6 +878,7 @@ async def verify_email( # Create the AccountEmail row from datetime import datetime as dt, UTC as utc_tz + account_email = AccountEmail( account_id=account.id, email=verification_token.new_email, @@ -806,7 +904,9 @@ async def verify_email( @router.post("/emails/promote") async def promote_email( request: Request, - email_id: int = Form(..., title="Email ID", description="ID of the email to promote"), + email_id: int = Form( + ..., title="Email ID", description="ID of the email to promote" + ), account: Account = Depends(get_authenticated_account), session: Session = Depends(get_session), ): @@ -824,6 +924,7 @@ async def promote_email( if not target_email: from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Email address not found") # If already primary, no-op @@ -867,7 +968,9 @@ async def promote_email( recovery_token_str = create_recovery_token(account.id, old_primary_email, session) session.commit() recovery_url = generate_recovery_url(recovery_token_str) - send_primary_email_changed_notification(old_primary_email, target_email.email, recovery_url) + send_primary_email_changed_notification( + old_primary_email, target_email.email, recovery_url + ) profile_path = user_router.url_path_for("read_profile") if is_htmx_request(request): @@ -877,12 +980,18 @@ async def promote_email( response = RedirectResponse(url=str(profile_path), status_code=303) set_flash_cookie(response, "Primary email address updated.") response.set_cookie( - key="access_token", value=access_token, - httponly=True, secure=COOKIE_SECURE, samesite="lax", + key="access_token", + value=access_token, + httponly=True, + secure=COOKIE_SECURE, + samesite="lax", ) response.set_cookie( - key="refresh_token", value=refresh_token, - httponly=True, secure=COOKIE_SECURE, samesite="lax", + key="refresh_token", + value=refresh_token, + httponly=True, + secure=COOKIE_SECURE, + samesite="lax", ) return response @@ -890,7 +999,9 @@ async def promote_email( @router.post("/emails/remove") async def remove_email( request: Request, - email_id: int = Form(..., title="Email ID", description="ID of the email to remove"), + email_id: int = Form( + ..., title="Email ID", description="ID of the email to remove" + ), account: Account = Depends(get_authenticated_account), session: Session = Depends(get_session), ): @@ -907,6 +1018,7 @@ async def remove_email( if not target_email: from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Email address not found") if target_email.is_primary: @@ -923,7 +1035,9 @@ async def remove_email( send_email_removed_notification(removed_address, recovery_url) if is_htmx_request(request): - return toast_response(request, templates, "Email address removed.", level="success") + return toast_response( + request, templates, "Email address removed.", level="success" + ) profile_path: URLPath = user_router.url_path_for("read_profile") response = RedirectResponse(url=str(profile_path), status_code=303) set_flash_cookie(response, "Email address removed.") diff --git a/routers/core/dashboard.py b/routers/core/dashboard.py index 6d1a871..82e8a95 100644 --- a/routers/core/dashboard.py +++ b/routers/core/dashboard.py @@ -1,7 +1,7 @@ from typing import Optional, List from fastapi import APIRouter, Depends, Request, Response from fastapi.templating import Jinja2Templates -from sqlmodel import Session, select +from sqlmodel import Session, select, col from utils.core.dependencies import get_user_with_relations, get_session from utils.core.models import User, Organization from utils.app.enums import AppPermissions @@ -44,11 +44,13 @@ async def read_dashboard( # Load organization resources for the selected org if selected_org and selected_org.id is not None: - resources = list(session.exec( - select(OrganizationResource) - .where(OrganizationResource.organization_id == selected_org.id) - .order_by(OrganizationResource.created_at.desc()) # type: ignore[union-attr] - ).all()) + resources = list( + session.exec( + select(OrganizationResource) + .where(OrganizationResource.organization_id == selected_org.id) + .order_by(col(OrganizationResource.created_at).desc()) + ).all() + ) can_read = user.has_permission( AppPermissions.READ_ORGANIZATION_RESOURCES, selected_org ) @@ -70,7 +72,7 @@ async def read_dashboard( "can_read": can_read, "can_write": can_write, "can_delete": can_delete, - } + }, ) diff --git a/routers/core/invitation.py b/routers/core/invitation.py index d74d950..da85913 100644 --- a/routers/core/invitation.py +++ b/routers/core/invitation.py @@ -8,7 +8,11 @@ from sqlmodel import Session, select from logging import getLogger -from utils.core.dependencies import get_authenticated_user, get_optional_user, get_session +from utils.core.dependencies import ( + get_authenticated_user, + get_optional_user, + get_session, +) from utils.core.models import User, Role, Account, Invitation, Organization from utils.core.enums import ValidPermissions from utils.core.invitations import send_invitation_email, process_invitation @@ -22,9 +26,12 @@ ) from exceptions.exceptions import EmailSendFailedError from utils.core.htmx import is_htmx_request, append_toast + # Import the account router to generate URLs for login/register from routers.core.account import router as account_router -from routers.core.organization import router as org_router # Already imported, check usage +from routers.core.organization import ( + router as org_router, +) # Already imported, check usage # Setup logger logger = getLogger("uvicorn.error") @@ -39,8 +46,7 @@ # Dependency to get a valid invitation def get_valid_invitation( - token: str = Query(...), - session: Session = Depends(get_session) + token: str = Query(...), session: Session = Depends(get_session) ) -> Invitation: """Dependency to retrieve a valid, active invitation based on the token.""" statement = select(Invitation).where(Invitation.token == token) @@ -55,41 +61,60 @@ async def create_invitation( request: Request, current_user: User = Depends(get_authenticated_user), session: Session = Depends(get_session), - invitee_email: EmailStr = Form(..., title="Invitee email", description="Email address of the person to invite"), - role_id: int = Form(..., title="Role ID", description="ID of the role to assign to the invitee"), - organization_id: int = Form(..., title="Organization ID", description="ID of the organization to invite the user to"), + invitee_email: EmailStr = Form( + ..., title="Invitee email", description="Email address of the person to invite" + ), + role_id: int = Form( + ..., title="Role ID", description="ID of the role to assign to the invitee" + ), + organization_id: int = Form( + ..., + title="Organization ID", + description="ID of the organization to invite the user to", + ), ): # Fetch the organization organization = session.get(Organization, organization_id) if not organization: raise OrganizationNotFoundError() - + # Check if the current user has permission to invite users to this organization if not current_user.has_permission(ValidPermissions.INVITE_USER, organization): - raise HTTPException(status_code=403, detail="You don't have permission to invite users to this organization") - + raise HTTPException( + status_code=403, + detail="You don't have permission to invite users to this organization", + ) + # Verify the role exists and belongs to this organization role = session.get(Role, role_id) if not role: raise HTTPException(status_code=404, detail="Role not found") if role.organization_id != organization_id: raise InvalidRoleForOrganizationError() - + # Check if invitee is already a member of the organization - existing_account = session.exec(select(Account).where(Account.email == invitee_email)).first() + existing_account = session.exec( + select(Account).where(Account.email == invitee_email) + ).first() if existing_account: # Check if any user with this account is already a member - existing_user = session.exec(select(User).where(User.account_id == existing_account.id)).first() + existing_user = session.exec( + select(User).where(User.account_id == existing_account.id) + ).first() if existing_user: # Check if user has any role in this organization - if any(role.organization_id == organization_id for role in existing_user.roles): + if any( + role.organization_id == organization_id for role in existing_user.roles + ): raise UserIsAlreadyMemberError() - + # Check for active invitations with the same email active_invitations = Invitation.get_active_for_org(session, organization_id) - if any(invitation.invitee_email == invitee_email for invitation in active_invitations): + if any( + invitation.invitee_email == invitee_email for invitation in active_invitations + ): raise ActiveInvitationExistsError() - + # Create the invitation token = str(uuid4()) invitation = Invitation( @@ -98,36 +123,38 @@ async def create_invitation( invitee_email=invitee_email, token=token, ) - + session.add(invitation) try: # Refresh to ensure relationships are loaded *before* sending email - session.flush() # Ensure invitation gets an ID if needed by email sender, flush changes + session.flush() # Ensure invitation gets an ID if needed by email sender, flush changes session.refresh(invitation) # Ensure organization is loaded before passing to email function # (May already be loaded, but explicit refresh is safer) if not invitation.organization: - session.refresh(organization) # Refresh the org object fetched earlier - invitation.organization = organization # Assign if needed + session.refresh(organization) # Refresh the org object fetched earlier + invitation.organization = organization # Assign if needed # Send email synchronously BEFORE committing send_invitation_email(invitation, session) # Commit *only* if email sending was successful session.commit() - session.refresh(invitation) # Refresh again after commit if needed elsewhere + session.refresh(invitation) # Refresh again after commit if needed elsewhere except EmailSendFailedError as e: - logger.error(f"Invitation email failed for {invitee_email} in org {organization_id}: {e}") - session.rollback() # Rollback the invitation creation - raise InvitationEmailSendError() # Raise HTTP 500 + logger.error( + f"Invitation email failed for {invitee_email} in org {organization_id}: {e}" + ) + session.rollback() # Rollback the invitation creation + raise InvitationEmailSendError() # Raise HTTP 500 except Exception as e: # Catch any other unexpected errors during flush/refresh/email/commit logger.error( f"Unexpected error during invitation creation/sending for {invitee_email} " f"in org {organization_id}: {e}", - exc_info=True + exc_info=True, ) session.rollback() raise HTTPException(status_code=500, detail="An unexpected error occurred.") @@ -141,7 +168,9 @@ async def create_invitation( {"active_invitations": active_invitations}, ) response.headers["HX-Trigger"] = "modalDismiss" - return append_toast(response, request, templates, "Invitation sent successfully.") + return append_toast( + response, request, templates, "Invitation sent successfully." + ) return RedirectResponse(url=f"/organizations/{organization_id}", status_code=303) @@ -162,11 +191,16 @@ async def accept_invitation( # Ensure the account relationship is loaded before accessing its email if not current_user.account: session.refresh(current_user, attribute_names=["account"]) - + # Check if refreshed account has an email (should always exist, but good practice) if not current_user.account or not current_user.account.email: - logger.error(f"User {current_user.id} is missing account details after refresh.") - raise HTTPException(status_code=500, detail="Internal server error retrieving user account.") + logger.error( + f"User {current_user.id} is missing account details after refresh." + ) + raise HTTPException( + status_code=500, + detail="Internal server error retrieving user account.", + ) # Logged in as the correct user, process the invitation directly logger.info( @@ -176,16 +210,22 @@ async def accept_invitation( process_invitation(invitation, current_user, session) session.commit() # Redirect to the organization page - redirect_url = org_router.url_path_for("read_organization", org_id=invitation.organization_id) - return RedirectResponse(url=str(redirect_url), status_code=status.HTTP_303_SEE_OTHER) + redirect_url = org_router.url_path_for( + "read_organization", org_id=invitation.organization_id + ) + return RedirectResponse( + url=str(redirect_url), status_code=status.HTTP_303_SEE_OTHER + ) except Exception as e: logger.error( f"Error processing invitation {invitation.id} for user {current_user.id}: {e}", - exc_info=True + exc_info=True, ) session.rollback() # Re-raise or return a generic error response - raise HTTPException(status_code=500, detail="Failed to process invitation.") + raise HTTPException( + status_code=500, detail="Failed to process invitation." + ) else: # Account exists, but user is not logged in or is the wrong user # Redirect to login, passing the token @@ -194,14 +234,16 @@ async def accept_invitation( ) login_url = account_router.url_path_for("read_login") redirect_url_with_token = f"{login_url}?invitation_token={invitation.token}" - return RedirectResponse(url=redirect_url_with_token, status_code=status.HTTP_303_SEE_OTHER) + return RedirectResponse( + url=redirect_url_with_token, status_code=status.HTTP_303_SEE_OTHER + ) else: # Account does not exist - redirect to registration logger.info( f"Invitation {invitation.id} requires registration for {invitation.invitee_email}. Redirecting." ) register_url = account_router.url_path_for("read_register") - redirect_url_with_params = ( - f"{register_url}?email={invitation.invitee_email}&invitation_token={invitation.token}" + redirect_url_with_params = f"{register_url}?email={invitation.invitee_email}&invitation_token={invitation.token}" + return RedirectResponse( + url=redirect_url_with_params, status_code=status.HTTP_303_SEE_OTHER ) - return RedirectResponse(url=redirect_url_with_params, status_code=status.HTTP_303_SEE_OTHER) diff --git a/routers/core/organization.py b/routers/core/organization.py index 5c63f39..9c30180 100644 --- a/routers/core/organization.py +++ b/routers/core/organization.py @@ -6,14 +6,22 @@ from sqlmodel import Session, select from sqlalchemy.orm import selectinload from utils.core.db import create_default_roles -from utils.core.dependencies import get_authenticated_user, get_user_with_relations, get_session +from utils.core.dependencies import ( + get_authenticated_user, + get_user_with_relations, + get_session, +) from utils.core.models import Organization, User, Role, Account, utc_now, Invitation from utils.core.enums import ValidPermissions from utils.app.enums import AppPermissions from exceptions.http_exceptions import ( - OrganizationNotFoundError, OrganizationNameTakenError, - InsufficientPermissionsError, OrganizationSetupError, - UserNotFoundError, UserAlreadyMemberError, DataIntegrityError + OrganizationNotFoundError, + OrganizationNameTakenError, + InsufficientPermissionsError, + OrganizationSetupError, + UserNotFoundError, + UserAlreadyMemberError, + DataIntegrityError, ) from pydantic import EmailStr from utils.core.htmx import is_htmx_request, set_flash_cookie @@ -32,69 +40,72 @@ async def read_organization( org_id: int, request: Request, user: User = Depends(get_user_with_relations), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ): # Get the organization only if the user is a member of it - org = next( - (org for org in user.organizations if org.id == org_id), - None - ) + org = next((org for org in user.organizations if org.id == org_id), None) if not org: raise OrganizationNotFoundError() - + # Calculate the user's permissions for this organization user_permissions = set() for role in user.roles: if role.organization_id == org_id: for permission in role.permissions: user_permissions.add(permission.name) - + # Load the organization with fully loaded roles and users organization = session.exec( select(Organization) .where(Organization.id == org_id) .options( - selectinload(Organization.roles).selectinload(Role.users).selectinload(User.account), - selectinload(Organization.roles).selectinload(Role.users).selectinload(User.roles), - selectinload(Organization.roles).selectinload(Role.permissions) + selectinload(Organization.roles) + .selectinload(Role.users) + .selectinload(User.account), + selectinload(Organization.roles) + .selectinload(Role.users) + .selectinload(User.roles), + selectinload(Organization.roles).selectinload(Role.permissions), ) ).first() - + # Fetch active invitations for the organization active_invitations = Invitation.get_active_for_org(session, org_id) - + # Pass all required context to the template return templates.TemplateResponse( - request, - "organization/organization.html", + request, + "organization/organization.html", { - "organization": organization, + "organization": organization, "user": user, "user_permissions": user_permissions, "ValidPermissions": ValidPermissions, "all_permissions": list(ValidPermissions) + list(AppPermissions), - "active_invitations": active_invitations - } + "active_invitations": active_invitations, + }, ) @router.post("/create", response_class=RedirectResponse) def create_organization( - name: Annotated[str, Form( - min_length=1, - strip_whitespace=True, - pattern=r"\S+", - description="Organization name cannot be empty or contain only whitespace", - title="Organization name" - )], + name: Annotated[ + str, + Form( + min_length=1, + strip_whitespace=True, + pattern=r"\S+", + description="Organization name cannot be empty or contain only whitespace", + title="Organization name", + ), + ], user: User = Depends(get_authenticated_user), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ) -> RedirectResponse: logger.debug(f"Received organization name: '{name}' (length: {len(name)})") - + # Check if organization already exists - db_org = session.exec(select(Organization).where( - Organization.name == name)).first() + db_org = session.exec(select(Organization).where(Organization.name == name)).first() if db_org: raise OrganizationNameTakenError() @@ -126,7 +137,9 @@ def create_organization( owner_role = next((role for role in db_org.roles if role.name == "Owner"), None) if owner_role is None: - logger.error(f"'Owner' role not found for newly created org ID {db_org.id} after create_default_roles call.") + logger.error( + f"'Owner' role not found for newly created org ID {db_org.id} after create_default_roles call." + ) # Rollback might be needed session.rollback() raise OrganizationSetupError("Owner role missing after creation") @@ -137,17 +150,20 @@ def create_organization( # Commit the user role link try: session.commit() - logger.info(f"Successfully created organization '{db_org.name}' (ID: {db_org.id}) and assigned owner (User ID: {user.id}).") + logger.info( + f"Successfully created organization '{db_org.name}' (ID: {db_org.id}) and assigned owner (User ID: {user.id})." + ) except Exception as e: - logger.exception(f"Failed to commit user-owner role link for org ID {db_org.id} and user ID {user.id}") + logger.exception( + f"Failed to commit user-owner role link for org ID {db_org.id} and user ID {user.id}" + ) session.rollback() raise OrganizationSetupError("Failed to assign owner role") from e - session.refresh(db_org) # Refresh again to be safe before redirect + session.refresh(db_org) # Refresh again to be safe before redirect return RedirectResponse( - url=router.url_path_for("read_organization", org_id=db_org.id), - status_code=303 + url=router.url_path_for("read_organization", org_id=db_org.id), status_code=303 ) @@ -155,22 +171,28 @@ def create_organization( def update_organization( request: Request, org_id: int, - name: Annotated[str, Form( - min_length=1, - strip_whitespace=True, - pattern=r"\S+", - description="Organization name cannot be empty or contain only whitespace", - title="Organization name" - )], + name: Annotated[ + str, + Form( + min_length=1, + strip_whitespace=True, + pattern=r"\S+", + description="Organization name cannot be empty or contain only whitespace", + title="Organization name", + ), + ], user: User = Depends(get_user_with_relations), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ) -> Response: # This will raise appropriate exceptions if org doesn't exist or user lacks access organization: Organization | None = next( - (org_item for org_item in user.organizations if org_item.id == org_id), None) + (org_item for org_item in user.organizations if org_item.id == org_id), None + ) # Check if user has permission to edit organization - if not organization or not user.has_permission(ValidPermissions.EDIT_ORGANIZATION, organization): + if not organization or not user.has_permission( + ValidPermissions.EDIT_ORGANIZATION, organization + ): raise InsufficientPermissionsError() # Check if new name already exists for another organization @@ -190,10 +212,14 @@ def update_organization( if is_htmx_request(request): response = Response(status_code=200) - response.headers["HX-Redirect"] = str(router.url_path_for("read_organization", org_id=org_id)) + response.headers["HX-Redirect"] = str( + router.url_path_for("read_organization", org_id=org_id) + ) set_flash_cookie(response, "Organization updated successfully.") return response - response = RedirectResponse(url=router.url_path_for("read_organization", org_id=org_id), status_code=303) + response = RedirectResponse( + url=router.url_path_for("read_organization", org_id=org_id), status_code=303 + ) set_flash_cookie(response, "Organization updated successfully.") return response @@ -203,19 +229,26 @@ def delete_organization( request: Request, org_id: int, user: User = Depends(get_user_with_relations), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ) -> Response: # Find the organization the user belongs to organization: Organization | None = next( - (org for org in user.organizations if org.id == org_id), None) + (org for org in user.organizations if org.id == org_id), None + ) # Check if the user is a member and has permission to delete the organization - if not organization or not user.has_permission(ValidPermissions.DELETE_ORGANIZATION, organization): - logger.warning(f"User {user.id} attempted to delete organization {org_id} without permission.") + if not organization or not user.has_permission( + ValidPermissions.DELETE_ORGANIZATION, organization + ): + logger.warning( + f"User {user.id} attempted to delete organization {org_id} without permission." + ) raise InsufficientPermissionsError() # Delete organization - logger.info(f"User {user.id} deleting organization {org_id} ('{organization.name}').") + logger.info( + f"User {user.id} deleting organization {org_id} ('{organization.name}')." + ) session.delete(organization) session.commit() @@ -232,24 +265,23 @@ def delete_organization( @router.post("/invite/{org_id}", response_class=RedirectResponse) def invite_member( org_id: int, - email: Annotated[EmailStr, Form( - description="Email of the user to invite", - title="Email" - )], + email: Annotated[ + EmailStr, Form(description="Email of the user to invite", title="Email") + ], user: User = Depends(get_user_with_relations), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ) -> RedirectResponse: # Check if the user has permission to invite members if not user.has_permission(ValidPermissions.INVITE_USER, org_id): raise InsufficientPermissionsError() - + # Find the organization with all needed relationships organization = session.exec( select(Organization) .where(Organization.id == org_id) .options( selectinload(Organization.roles), - selectinload(Organization.roles).selectinload(Role.users) + selectinload(Organization.roles).selectinload(Role.users), ) ).first() @@ -260,11 +292,9 @@ def invite_member( account = session.exec( select(Account) .where(Account.email == email) - .options( - selectinload(Account.user) - ) + .options(selectinload(Account.user)) ).first() - + if not account or not account.user: raise UserNotFoundError() @@ -282,8 +312,7 @@ def invite_member( # Find the default "Member" role for this organization member_role = next( - (role for role in organization.roles if role.name == "Member"), - None + (role for role in organization.roles if role.name == "Member"), None ) if not member_role: @@ -299,6 +328,5 @@ def invite_member( # Return to the organization page return RedirectResponse( - url=router.url_path_for("read_organization", org_id=org_id), - status_code=303 + url=router.url_path_for("read_organization", org_id=org_id), status_code=303 ) diff --git a/routers/core/role.py b/routers/core/role.py index b59d6f9..89c038c 100644 --- a/routers/core/role.py +++ b/routers/core/role.py @@ -9,10 +9,24 @@ from sqlalchemy.orm import selectinload from sqlalchemy.exc import IntegrityError from utils.core.dependencies import get_authenticated_user, get_session -from utils.core.models import Role, Permission, utc_now, User, DataIntegrityError, Organization +from utils.core.models import ( + Role, + Permission, + utc_now, + User, + DataIntegrityError, + Organization, +) from utils.core.enums import ValidPermissions from utils.app.enums import AppPermissions -from exceptions.http_exceptions import InsufficientPermissionsError, InvalidPermissionError, RoleAlreadyExistsError, RoleNotFoundError, RoleHasUsersError, CannotModifyDefaultRoleError +from exceptions.http_exceptions import ( + InsufficientPermissionsError, + InvalidPermissionError, + RoleAlreadyExistsError, + RoleNotFoundError, + RoleHasUsersError, + CannotModifyDefaultRoleError, +) from routers.core.organization import router as organization_router from utils.core.htmx import is_htmx_request, append_toast @@ -22,7 +36,9 @@ templates = Jinja2Templates(directory="templates") -def _load_org_for_roles_partial(session: Session, organization_id: int, user: User) -> tuple: +def _load_org_for_roles_partial( + session: Session, organization_id: int, user: User +) -> tuple: """Re-query org with roles/users/permissions and compute user_permissions.""" organization = session.exec( select(Organization) @@ -42,21 +58,35 @@ def _load_org_for_roles_partial(session: Session, organization_id: int, user: Us # --- Routes --- + @router.post("/create", response_class=RedirectResponse) def create_role( request: Request, - name: Annotated[str, Form(min_length=1, strip_whitespace=True, title="Role name", description="Name for the new role")], - organization_id: int = Form(..., title="Organization ID", description="ID of the organization this role belongs to"), - permissions: List[str] = Form(default=[], title="Permissions", description="List of permissions to assign to this role"), + name: Annotated[ + str, + Form( + min_length=1, + strip_whitespace=True, + title="Role name", + description="Name for the new role", + ), + ], + organization_id: int = Form( + ..., + title="Organization ID", + description="ID of the organization this role belongs to", + ), + permissions: List[str] = Form( + default=[], + title="Permissions", + description="List of permissions to assign to this role", + ), user: User = Depends(get_authenticated_user), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ): # Check that the user-selected role name is unique for the organization if session.exec( - select(Role).where( - Role.name == name, - Role.organization_id == organization_id - ) + select(Role).where(Role.name == name, Role.organization_id == organization_id) ).first(): raise RoleAlreadyExistsError() @@ -65,10 +95,7 @@ def create_role( raise InsufficientPermissionsError() # Create role - db_role = Role( - name=name, - organization_id=organization_id - ) + db_role = Role(name=name, organization_id=organization_id) session.add(db_role) # Select Permission records corresponding to the user-selected permissions @@ -87,7 +114,9 @@ def create_role( raise RoleAlreadyExistsError() if is_htmx_request(request): - organization, user_permissions = _load_org_for_roles_partial(session, organization_id, user) + organization, user_permissions = _load_org_for_roles_partial( + session, organization_id, user + ) response = templates.TemplateResponse( request, "organization/partials/roles_table.html", @@ -102,8 +131,10 @@ def create_role( response.headers["HX-Trigger"] = "modalDismiss" return append_toast(response, request, templates, "Role created successfully.") return RedirectResponse( - url=organization_router.url_path_for("read_organization", org_id=organization_id), - status_code=303 + url=organization_router.url_path_for( + "read_organization", org_id=organization_id + ), + status_code=303, ) @@ -111,11 +142,25 @@ def create_role( def update_role( request: Request, id: int = Form(..., title="Role ID", description="ID of the role to update"), - name: str = Form(..., min_length=1, strip_whitespace=True, title="Role name", description="Updated name for the role"), - organization_id: int = Form(..., title="Organization ID", description="ID of the organization this role belongs to"), - permissions: List[str] = Form(default=[], title="Permissions", description="Updated list of permissions for this role"), + name: str = Form( + ..., + min_length=1, + strip_whitespace=True, + title="Role name", + description="Updated name for the role", + ), + organization_id: int = Form( + ..., + title="Organization ID", + description="ID of the organization this role belongs to", + ), + permissions: List[str] = Form( + default=[], + title="Permissions", + description="Updated list of permissions for this role", + ), user: User = Depends(get_authenticated_user), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ): # Check that the user is authorized to update the role if not user.has_permission(ValidPermissions.EDIT_ROLE, organization_id): @@ -123,8 +168,7 @@ def update_role( # Select db_role to update, along with its permissions, by ID db_role: Optional[Role] = session.exec( - select(Role).where(Role.id == id).options( - selectinload(Role.permissions)) + select(Role).where(Role.id == id).options(selectinload(Role.permissions)) ).first() if not db_role: @@ -159,9 +203,7 @@ def update_role( # Check that no existing organization role has the same name but a different ID if session.exec( select(Role).where( - Role.name == name, - Role.organization_id == organization_id, - Role.id != id + Role.name == name, Role.organization_id == organization_id, Role.id != id ) ).first(): raise RoleAlreadyExistsError() @@ -179,7 +221,9 @@ def update_role( session.refresh(db_role) if is_htmx_request(request): - organization, user_permissions = _load_org_for_roles_partial(session, organization_id, user) + organization, user_permissions = _load_org_for_roles_partial( + session, organization_id, user + ) response = templates.TemplateResponse( request, "organization/partials/roles_table.html", @@ -194,8 +238,10 @@ def update_role( response.headers["HX-Trigger"] = "modalDismiss" return append_toast(response, request, templates, "Role updated successfully.") return RedirectResponse( - url=organization_router.url_path_for("read_organization", org_id=organization_id), - status_code=303 + url=organization_router.url_path_for( + "read_organization", org_id=organization_id + ), + status_code=303, ) @@ -203,9 +249,13 @@ def update_role( def delete_role( request: Request, id: int = Form(..., title="Role ID", description="ID of the role to delete"), - organization_id: int = Form(..., title="Organization ID", description="ID of the organization this role belongs to"), + organization_id: int = Form( + ..., + title="Organization ID", + description="ID of the organization this role belongs to", + ), user: User = Depends(get_authenticated_user), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ): # Check that the user is authorized to delete the role if not user.has_permission(ValidPermissions.DELETE_ROLE, organization_id): @@ -213,9 +263,7 @@ def delete_role( # Select the role to delete by ID, along with its users db_role: Role | None = session.exec( - select(Role).where(Role.id == id).options( - selectinload(Role.users) - ) + select(Role).where(Role.id == id).options(selectinload(Role.users)) ).first() if not db_role: @@ -234,7 +282,9 @@ def delete_role( session.commit() if is_htmx_request(request): - organization, user_permissions = _load_org_for_roles_partial(session, organization_id, user) + organization, user_permissions = _load_org_for_roles_partial( + session, organization_id, user + ) response = templates.TemplateResponse( request, "organization/partials/roles_table.html", @@ -248,6 +298,8 @@ def delete_role( ) return append_toast(response, request, templates, "Role deleted successfully.") return RedirectResponse( - url=organization_router.url_path_for("read_organization", org_id=organization_id), - status_code=303 + url=organization_router.url_path_for( + "read_organization", org_id=organization_id + ), + status_code=303, ) diff --git a/routers/core/static_pages.py b/routers/core/static_pages.py index 5d7543b..ca859fb 100644 --- a/routers/core/static_pages.py +++ b/routers/core/static_pages.py @@ -11,34 +11,29 @@ VALID_PAGES = { "about": "static_pages/about.html", "privacy-policy": "static_pages/privacy_policy.html", - "terms-of-service": "static_pages/terms_of_service.html" + "terms-of-service": "static_pages/terms_of_service.html", } + @router.get("/{page_name}", name="read_static_page") async def read_static_page( - page_name: str, - request: Request, - user: Optional[User] = Depends(get_optional_user) + page_name: str, request: Request, user: Optional[User] = Depends(get_optional_user) ): """ Generic handler for static pages. - + Args: page_name: The name of the page to render (must be in VALID_PAGES). request: The FastAPI request object. user: The optional authenticated user. - + Returns: TemplateResponse for the requested page. - + Raises: HTTPException: If the page_name is not in VALID_PAGES. """ if page_name not in VALID_PAGES: raise HTTPException(status_code=404, detail="Page not found") - - return templates.TemplateResponse( - request, - VALID_PAGES[page_name], - {"user": user} - ) \ No newline at end of file + + return templates.TemplateResponse(request, VALID_PAGES[page_name], {"user": user}) diff --git a/routers/core/user.py b/routers/core/user.py index e47ec9e..1d04b93 100644 --- a/routers/core/user.py +++ b/routers/core/user.py @@ -1,19 +1,37 @@ from fastapi import APIRouter, Depends, Form, UploadFile, File, Request, HTTPException from fastapi.responses import RedirectResponse, Response -from sqlmodel import Session, select +from sqlmodel import Session, select, col from typing import Optional, List from fastapi.templating import Jinja2Templates from sqlalchemy.orm import selectinload -from utils.core.models import User, UserAvatar, AccountEmail, DataIntegrityError, Organization, Role, Invitation +from utils.core.models import ( + User, + UserAvatar, + AccountEmail, + DataIntegrityError, + Organization, + Role, + Invitation, +) from utils.core.auth import MAX_EMAILS_PER_ACCOUNT -from utils.core.dependencies import get_authenticated_user, get_user_with_relations, get_session -from utils.core.images import validate_and_process_image, MAX_FILE_SIZE, MIN_DIMENSION, MAX_DIMENSION, ALLOWED_CONTENT_TYPES +from utils.core.dependencies import ( + get_authenticated_user, + get_user_with_relations, + get_session, +) +from utils.core.images import ( + validate_and_process_image, + MAX_FILE_SIZE, + MIN_DIMENSION, + MAX_DIMENSION, + ALLOWED_CONTENT_TYPES, +) from utils.core.enums import ValidPermissions from utils.app.enums import AppPermissions from exceptions.http_exceptions import ( InsufficientPermissionsError, UserNotFoundError, - OrganizationNotFoundError + OrganizationNotFoundError, ) from routers.core.organization import router as organization_router from utils.core.htmx import is_htmx_request, append_toast @@ -22,14 +40,20 @@ templates = Jinja2Templates(directory="templates") -def _load_org_for_members_partial(session: Session, organization_id: int, user: User) -> tuple: +def _load_org_for_members_partial( + session: Session, organization_id: int, user: User +) -> tuple: """Re-query org with members fully loaded and compute user_permissions.""" organization = session.exec( select(Organization) .where(Organization.id == organization_id) .options( - selectinload(Organization.roles).selectinload(Role.users).selectinload(User.account), - selectinload(Organization.roles).selectinload(Role.users).selectinload(User.roles), + selectinload(Organization.roles) + .selectinload(Role.users) + .selectinload(User.account), + selectinload(Organization.roles) + .selectinload(Role.users) + .selectinload(User.roles), selectinload(Organization.roles).selectinload(Role.permissions), ) ).first() @@ -50,23 +74,28 @@ async def read_profile( request: Request, user: User = Depends(get_user_with_relations), session: Session = Depends(get_session), - show_form: Optional[str] = "true" + show_form: Optional[str] = "true", ): # Load account emails - account_emails = session.exec( - select(AccountEmail) - .where(AccountEmail.account_id == user.account_id) - .order_by(AccountEmail.is_primary.desc()) # type: ignore[union-attr] - ).all() if user.account_id else [] + account_emails = ( + session.exec( + select(AccountEmail) + .where(AccountEmail.account_id == user.account_id) + .order_by(col(AccountEmail.is_primary).desc()) + ).all() + if user.account_id + else [] + ) return templates.TemplateResponse( request, - "users/profile.html", { + "users/profile.html", + { "show_form": show_form == "true", "user": user, "account_emails": account_emails, "max_emails": MAX_EMAILS_PER_ACCOUNT, - } + }, ) @@ -76,7 +105,9 @@ async def edit_profile_form( user: User = Depends(get_authenticated_user), ): if not is_htmx_request(request): - return RedirectResponse(url=router.url_path_for("read_profile"), status_code=303) + return RedirectResponse( + url=router.url_path_for("read_profile"), status_code=303 + ) return templates.TemplateResponse( request, "users/partials/profile_form.html", @@ -96,7 +127,9 @@ async def profile_display( user: User = Depends(get_authenticated_user), ): if not is_htmx_request(request): - return RedirectResponse(url=router.url_path_for("read_profile"), status_code=303) + return RedirectResponse( + url=router.url_path_for("read_profile"), status_code=303 + ) return templates.TemplateResponse( request, "users/partials/profile_display.html", @@ -107,10 +140,12 @@ async def profile_display( @router.post("/update", response_class=RedirectResponse) async def update_profile( request: Request, - name: Optional[str] = Form(None, strip_whitespace=True, title="Name", description="Updated display name"), + name: Optional[str] = Form( + None, strip_whitespace=True, title="Name", description="Updated display name" + ), avatar_file: Optional[UploadFile] = File(None), user: User = Depends(get_authenticated_user), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ): avatar_changed = bool(avatar_file and avatar_file.filename) @@ -121,8 +156,7 @@ async def update_profile( avatar_content_type = avatar_file.content_type processed_image, content_type = validate_and_process_image( - avatar_data, - avatar_content_type + avatar_data, avatar_content_type ) if user.avatar: user.avatar.avatar_data = processed_image @@ -132,7 +166,7 @@ async def update_profile( user.avatar = UserAvatar( user_id=user.id, avatar_data=processed_image, - avatar_content_type=content_type + avatar_content_type=content_type, ) # Update user details @@ -149,42 +183,47 @@ async def update_profile( ) if avatar_changed: # Avatar also appears in the navbar — append an OOB swap for it. - navbar_html = bytes(templates.TemplateResponse( - request, - "base/partials/navbar_avatar_oob.html", - {"user": user}, - ).body).decode() + navbar_html = bytes( + templates.TemplateResponse( + request, + "base/partials/navbar_avatar_oob.html", + {"user": user}, + ).body + ).decode() original = bytes(response.body).decode() response.body = (original + navbar_html).encode() response.headers["content-length"] = str(len(response.body)) - return append_toast(response, request, templates, "Profile updated successfully.") + return append_toast( + response, request, templates, "Profile updated successfully." + ) return RedirectResponse(url=router.url_path_for("read_profile"), status_code=303) @router.get("/avatar") -async def get_avatar( - user: User = Depends(get_authenticated_user) -): +async def get_avatar(user: User = Depends(get_authenticated_user)): """Serve avatar image from database""" if not user.avatar: - raise DataIntegrityError( - resource="User avatar" - ) + raise DataIntegrityError(resource="User avatar") return Response( - content=user.avatar.avatar_data, - media_type=user.avatar.avatar_content_type + content=user.avatar.avatar_data, media_type=user.avatar.avatar_content_type ) @router.post("/role/update", response_class=RedirectResponse) def update_user_role( request: Request, - user_id: int = Form(..., title="User ID", description="ID of the user whose roles are being updated"), - organization_id: int = Form(..., title="Organization ID", description="ID of the organization"), - roles: Optional[List[int]] = Form(None, title="Role IDs", description="List of role IDs to assign to the user"), + user_id: int = Form( + ..., title="User ID", description="ID of the user whose roles are being updated" + ), + organization_id: int = Form( + ..., title="Organization ID", description="ID of the organization" + ), + roles: Optional[List[int]] = Form( + None, title="Role IDs", description="List of role IDs to assign to the user" + ), user: User = Depends(get_authenticated_user), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ) -> Response: """Update the roles of a user in an organization""" # Check if the current user has permission to edit user roles @@ -203,9 +242,7 @@ def update_user_role( # Find the target user target_user = session.exec( - select(User) - .where(User.id == user_id) - .options(selectinload(User.roles)) + select(User).where(User.id == user_id).options(selectinload(User.roles)) ).first() if not target_user: @@ -229,7 +266,9 @@ def update_user_role( session.commit() if is_htmx_request(request): - organization, user_permissions, active_invitations = _load_org_for_members_partial(session, organization_id, user) + organization, user_permissions, active_invitations = ( + _load_org_for_members_partial(session, organization_id, user) + ) response = templates.TemplateResponse( request, "organization/partials/members_table.html", @@ -243,10 +282,14 @@ def update_user_role( }, ) response.headers["HX-Trigger"] = "modalDismiss" - return append_toast(response, request, templates, "User role updated successfully.") + return append_toast( + response, request, templates, "User role updated successfully." + ) return RedirectResponse( - url=organization_router.url_path_for("read_organization", org_id=organization_id), - status_code=303 + url=organization_router.url_path_for( + "read_organization", org_id=organization_id + ), + status_code=303, ) @@ -254,9 +297,13 @@ def update_user_role( def remove_user_from_organization( request: Request, user_id: int = Form(..., title="User ID", description="ID of the user to remove"), - organization_id: int = Form(..., title="Organization ID", description="ID of the organization to remove the user from"), + organization_id: int = Form( + ..., + title="Organization ID", + description="ID of the organization to remove the user from", + ), user: User = Depends(get_authenticated_user), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ) -> Response: """Remove a user from an organization by removing all their roles in that organization""" # Check if the current user has permission to remove users @@ -265,8 +312,7 @@ def remove_user_from_organization( # Find the organization organization = session.exec( - select(Organization) - .where(Organization.id == organization_id) + select(Organization).where(Organization.id == organization_id) ).first() if not organization: @@ -274,9 +320,7 @@ def remove_user_from_organization( # Find the target user target_user = session.exec( - select(User) - .where(User.id == user_id) - .options(selectinload(User.roles)) + select(User).where(User.id == user_id).options(selectinload(User.roles)) ).first() if not target_user: @@ -285,8 +329,7 @@ def remove_user_from_organization( # Prevent removing oneself if target_user.id == user.id: raise HTTPException( - status_code=400, - detail="You cannot remove yourself from the organization" + status_code=400, detail="You cannot remove yourself from the organization" ) # Remove all organization roles from the user @@ -297,7 +340,9 @@ def remove_user_from_organization( session.commit() if is_htmx_request(request): - organization, user_permissions, active_invitations = _load_org_for_members_partial(session, organization_id, user) + organization, user_permissions, active_invitations = ( + _load_org_for_members_partial(session, organization_id, user) + ) response = templates.TemplateResponse( request, "organization/partials/members_table.html", @@ -310,8 +355,12 @@ def remove_user_from_organization( "all_permissions": list(ValidPermissions) + list(AppPermissions), }, ) - return append_toast(response, request, templates, "User removed from organization.") + return append_toast( + response, request, templates, "User removed from organization." + ) return RedirectResponse( - url=organization_router.url_path_for("read_organization", org_id=organization_id), - status_code=303 + url=organization_router.url_path_for( + "read_organization", org_id=organization_id + ), + status_code=303, ) diff --git a/static/js/app.js b/static/js/app.js index 8d66d4d..55ff3e7 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -21,6 +21,12 @@ document.body.addEventListener('htmx:configRequest', function() { // clean up any Bootstrap modal backdrop left behind by OOB swaps that // replaced the modal element before afterRequest could call .hide(). document.body.addEventListener('modalDismiss', function() { + document.querySelectorAll('.modal.show').forEach(function(el) { + var modal = bootstrap.Modal.getInstance(el); + if (modal) { + modal.hide(); + } + }); document.querySelectorAll('.modal-backdrop').forEach(function(el) { el.remove(); }); document.body.classList.remove('modal-open'); document.body.style.removeProperty('overflow'); diff --git a/tests/browser/conftest.py b/tests/browser/conftest.py index 9aba05a..dde6963 100644 --- a/tests/browser/conftest.py +++ b/tests/browser/conftest.py @@ -4,7 +4,12 @@ import os import socket from dotenv import load_dotenv -from utils.core.db import get_connection_url, set_up_db, tear_down_db, ensure_database_exists +from utils.core.db import ( + get_connection_url, + set_up_db, + tear_down_db, + ensure_database_exists, +) def _port_free(port: int) -> bool: diff --git a/tests/browser/test_bootstrap_dropdown.py b/tests/browser/test_bootstrap_dropdown.py index a721ef6..37299e7 100644 --- a/tests/browser/test_bootstrap_dropdown.py +++ b/tests/browser/test_bootstrap_dropdown.py @@ -6,6 +6,7 @@ or the app's custom delegation handler is lost during the swap, clicking [data-bs-toggle="dropdown"] elements does nothing until a full page refresh. """ + import pytest from playwright.sync_api import Page, expect @@ -21,7 +22,9 @@ def _register_user(browser, live_server: str): p.fill("#password", "TestPass123!@#") p.fill("#confirm_password", "TestPass123!@#") p.click('button[type="submit"]') - p.wait_for_function("window.location.pathname.startsWith('/dashboard')", timeout=10_000) + p.wait_for_function( + "window.location.pathname.startsWith('/dashboard')", timeout=10_000 + ) context.close() @@ -34,7 +37,9 @@ def logged_in_page(browser, live_server: str, _register_user): p.fill("#email", "playwright@example.com") p.fill("#password", "TestPass123!@#") p.click('button[type="submit"]') - p.wait_for_function("window.location.pathname.startsWith('/dashboard')", timeout=10_000) + p.wait_for_function( + "window.location.pathname.startsWith('/dashboard')", timeout=10_000 + ) yield p context.close() @@ -48,7 +53,7 @@ def test_profile_dropdown_works_on_initial_load(logged_in_page: Page): expect(dropdown_toggle).to_be_visible(timeout=2_000) dropdown_toggle.click() - dropdown_menu = page.locator('.nav-item.dropdown .dropdown-menu') + dropdown_menu = page.locator(".nav-item.dropdown .dropdown-menu") expect(dropdown_menu).to_be_visible(timeout=2_000) @@ -75,5 +80,5 @@ def test_profile_dropdown_works_after_boost_navigation( dropdown_toggle.click() # The dropdown menu should become visible. - dropdown_menu = page.locator('.nav-item.dropdown .dropdown-menu') + dropdown_menu = page.locator(".nav-item.dropdown .dropdown-menu") expect(dropdown_menu).to_be_visible(timeout=2_000) diff --git a/tests/browser/test_profile_forms.py b/tests/browser/test_profile_forms.py index 73663cb..53ce3fe 100644 --- a/tests/browser/test_profile_forms.py +++ b/tests/browser/test_profile_forms.py @@ -4,6 +4,7 @@ 1. Edit profile form: clicking Edit fetches form via hx-get, submitting swaps back to display 2. Add email form: after submit, the email input is cleared """ + import pytest from playwright.sync_api import Page, expect diff --git a/tests/browser/test_toast_display.py b/tests/browser/test_toast_display.py index 9af4788..8026966 100644 --- a/tests/browser/test_toast_display.py +++ b/tests/browser/test_toast_display.py @@ -8,6 +8,7 @@ Also verifies auto-dismiss (~5 s) and manual close via the X button. """ + import pytest from PIL import Image from playwright.sync_api import Page, expect @@ -139,7 +140,6 @@ def test_avatar_update_no_full_reload(logged_in_page: Page, live_server: str, tm assert marker is True, "Page was fully reloaded instead of using OOB swaps" - # --- 2. HTMX error toast (error response path) --- diff --git a/tests/conftest.py b/tests/conftest.py index d979420..6b78bc7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,18 +1,37 @@ import pytest import os -from typing import Generator +from typing import Generator, cast from sqlmodel import create_engine, Session, select from fastapi.testclient import TestClient from dotenv import load_dotenv -from utils.core.db import get_connection_url, tear_down_db, set_up_db, create_default_roles, ensure_database_exists -from utils.core.models import User, Organization, Role, Account, AccountEmail, Invitation -from utils.core.auth import get_password_hash, create_access_token, create_tracked_refresh_token +from utils.core.db import ( + get_connection_url, + tear_down_db, + set_up_db, + create_default_roles, + ensure_database_exists, +) +from utils.core.models import ( + User, + Organization, + Role, + Account, + AccountEmail, + Invitation, +) +from utils.core.auth import ( + get_password_hash, + create_access_token, + create_tracked_refresh_token, +) from main import app from datetime import datetime, UTC, timedelta + # Define a custom exception for test setup errors class SetupError(Exception): """Exception raised for errors in the test setup process.""" + def __init__(self, message="An error occurred during test setup"): self.message = message super().__init__(self.message) @@ -70,8 +89,7 @@ def test_account(session: Session) -> Account: Creates a test account in the database. """ account = Account( - email="test@example.com", - hashed_password=get_password_hash("Test123!@#") + email="test@example.com", hashed_password=get_password_hash("Test123!@#") ) session.add(account) session.commit() @@ -84,14 +102,11 @@ def test_user(session: Session, test_account: Account) -> User: """ Creates a test user in the database linked to the test account. """ - user = User( - name="Test User", - account_id=test_account.id - ) + user = User(name="Test User", account_id=test_account.id) session.add(user) session.commit() session.refresh(user) - + # Also refresh the account to ensure the relationship is loaded session.refresh(test_account) return user @@ -125,7 +140,9 @@ def unauth_client(session: Session) -> Generator[TestClient, None, None]: @pytest.fixture -def auth_client(session: Session, test_account: Account, test_user: User) -> Generator[TestClient, None, None]: +def auth_client( + session: Session, test_account: Account, test_user: User +) -> Generator[TestClient, None, None]: """ Provides a TestClient instance with valid authentication tokens. """ @@ -133,7 +150,9 @@ def auth_client(session: Session, test_account: Account, test_user: User) -> Gen # Create and set valid tokens access_token = create_access_token({"sub": test_account.email}) - refresh_token = create_tracked_refresh_token(test_account.id, test_account.email, session) + refresh_token = create_tracked_refresh_token( + test_account.id, test_account.email, session + ) session.commit() client.cookies.set("access_token", access_token) @@ -164,18 +183,14 @@ def org_owner(session: Session, test_organization: Organization) -> User: """Create a user who is the owner of the test organization""" # Create account account = Account( - email="owner@example.com", - hashed_password=get_password_hash("Owner123!@#") + email="owner@example.com", hashed_password=get_password_hash("Owner123!@#") ) session.add(account) session.commit() session.refresh(account) - + # Create user - user = User( - name="Org Owner", - account_id=account.id - ) + user = User(name="Org Owner", account_id=account.id) session.add(user) # Find the Owner role for the test organization owner_role = session.exec( @@ -189,7 +204,7 @@ def org_owner(session: Session, test_organization: Organization) -> User: # Assign user to owner role user.roles.append(owner_role) - + session.commit() session.refresh(user) return user @@ -200,20 +215,16 @@ def org_admin_user(session: Session, test_organization: Organization) -> User: """Create a user with Administrator role in the test organization""" # Create account account = Account( - email="admin@example.com", - hashed_password=get_password_hash("Admin123!@#") + email="admin@example.com", hashed_password=get_password_hash("Admin123!@#") ) session.add(account) session.commit() session.refresh(account) - + # Create user - user = User( - name="Admin User", - account_id=account.id - ) + user = User(name="Admin User", account_id=account.id) session.add(user) - + # Find the Admin role for the test organization (already created with permissions) admin_role = session.exec( select(Role) @@ -226,7 +237,7 @@ def org_admin_user(session: Session, test_organization: Organization) -> User: # Assign role to user user.roles.append(admin_role) - + session.commit() session.refresh(user) return user @@ -237,20 +248,16 @@ def org_member_user(session: Session, test_organization: Organization) -> User: """Create a user with basic Member role in the test organization""" # Create account account = Account( - email="member@example.com", - hashed_password=get_password_hash("Member123!@#") + email="member@example.com", hashed_password=get_password_hash("Member123!@#") ) session.add(account) session.commit() session.refresh(account) - + # Create user - user = User( - name="Member User", - account_id=account.id - ) + user = User(name="Member User", account_id=account.id) session.add(user) - + # Find the Member role for the test organization (already created) member_role = session.exec( select(Role) @@ -263,7 +270,7 @@ def org_member_user(session: Session, test_organization: Organization) -> User: # Assign role to user user.roles.append(member_role) - + session.commit() session.refresh(user) return user @@ -275,16 +282,13 @@ def non_member_user(session: Session) -> User: # Create account account = Account( email="nonmember@example.com", - hashed_password=get_password_hash("NonMember123!@#") + hashed_password=get_password_hash("NonMember123!@#"), ) session.add(account) session.commit() - + # Create user - user = User( - name="Non-Member User", - account_id=account.id - ) + user = User(name="Non-Member User", account_id=account.id) session.add(user) session.commit() session.refresh(user) @@ -292,18 +296,22 @@ def non_member_user(session: Session) -> User: @pytest.fixture -def auth_client_owner(session: Session, org_owner: User) -> Generator[TestClient, None, None]: +def auth_client_owner( + session: Session, org_owner: User +) -> Generator[TestClient, None, None]: """Provides a TestClient authenticated as the organization owner""" client = TestClient(app, follow_redirects=False) # Initialize tokens access_token = "" refresh_token = "" - + # Create and set valid tokens if org_owner.account: access_token = create_access_token({"sub": org_owner.account.email}) - refresh_token = create_tracked_refresh_token(org_owner.account.id, org_owner.account.email, session) + refresh_token = create_tracked_refresh_token( + org_owner.account.id, org_owner.account.email, session + ) session.commit() client.cookies.set("access_token", access_token) @@ -313,18 +321,22 @@ def auth_client_owner(session: Session, org_owner: User) -> Generator[TestClient @pytest.fixture -def auth_client_admin(session: Session, org_admin_user: User) -> Generator[TestClient, None, None]: +def auth_client_admin( + session: Session, org_admin_user: User +) -> Generator[TestClient, None, None]: """Provides a TestClient authenticated as an organization administrator""" client = TestClient(app, follow_redirects=False) # Initialize tokens access_token = "" refresh_token = "" - + # Create and set valid tokens if org_admin_user.account: access_token = create_access_token({"sub": org_admin_user.account.email}) - refresh_token = create_tracked_refresh_token(org_admin_user.account.id, org_admin_user.account.email, session) + refresh_token = create_tracked_refresh_token( + org_admin_user.account.id, org_admin_user.account.email, session + ) session.commit() client.cookies.set("access_token", access_token) @@ -334,18 +346,22 @@ def auth_client_admin(session: Session, org_admin_user: User) -> Generator[TestC @pytest.fixture -def auth_client_member(session: Session, org_member_user: User) -> Generator[TestClient, None, None]: +def auth_client_member( + session: Session, org_member_user: User +) -> Generator[TestClient, None, None]: """Provides a TestClient authenticated as the organization member""" client = TestClient(app, follow_redirects=False) # Initialize tokens access_token = "" refresh_token = "" - + # Create and set valid tokens if org_member_user.account: access_token = create_access_token({"sub": org_member_user.account.email}) - refresh_token = create_tracked_refresh_token(org_member_user.account.id, org_member_user.account.email, session) + refresh_token = create_tracked_refresh_token( + org_member_user.account.id, org_member_user.account.email, session + ) session.commit() client.cookies.set("access_token", access_token) @@ -355,18 +371,22 @@ def auth_client_member(session: Session, org_member_user: User) -> Generator[Tes @pytest.fixture -def auth_client_non_member(session: Session, non_member_user: User) -> Generator[TestClient, None, None]: +def auth_client_non_member( + session: Session, non_member_user: User +) -> Generator[TestClient, None, None]: """Provides a TestClient authenticated as a non-member""" client = TestClient(app, follow_redirects=False) # Initialize tokens access_token = "" refresh_token = "" - + # Create and set valid tokens if non_member_user.account: access_token = create_access_token({"sub": non_member_user.account.email}) - refresh_token = create_tracked_refresh_token(non_member_user.account.id, non_member_user.account.email, session) + refresh_token = create_tracked_refresh_token( + non_member_user.account.id, non_member_user.account.email, session + ) session.commit() client.cookies.set("access_token", access_token) @@ -386,6 +406,7 @@ def second_test_organization(session: Session) -> Organization: # --- Invitation Fixtures --- + @pytest.fixture def member_role(session: Session, test_organization: Organization) -> Role: """Returns the 'Member' role for the test_organization.""" @@ -401,7 +422,9 @@ def member_role(session: Session, test_organization: Organization) -> Role: @pytest.fixture -def test_invitation(session: Session, test_organization: Organization, member_role: Role) -> Invitation: +def test_invitation( + session: Session, test_organization: Organization, member_role: Role +) -> Invitation: """Creates a valid, active Invitation for invitee@example.com.""" # Assert IDs are not None to satisfy type checker assert test_organization.id is not None @@ -411,7 +434,7 @@ def test_invitation(session: Session, test_organization: Organization, member_ro role_id=member_role.id, invitee_email="invitee@example.com", token="valid-test-token", - expires_at=datetime.now(UTC) + timedelta(days=7) + expires_at=datetime.now(UTC) + timedelta(days=7), ) session.add(invitation) session.commit() @@ -420,7 +443,9 @@ def test_invitation(session: Session, test_organization: Organization, member_ro @pytest.fixture -def expired_invitation(session: Session, test_organization: Organization, member_role: Role) -> Invitation: +def expired_invitation( + session: Session, test_organization: Organization, member_role: Role +) -> Invitation: """Creates an Invitation with an expiration date in the past.""" # Assert IDs are not None to satisfy type checker assert test_organization.id is not None @@ -430,7 +455,7 @@ def expired_invitation(session: Session, test_organization: Organization, member role_id=member_role.id, invitee_email="expired-invitee@example.com", token="expired-test-token", - expires_at=datetime.now(UTC) - timedelta(days=1) + expires_at=datetime.now(UTC) - timedelta(days=1), ) session.add(invitation) session.commit() @@ -439,7 +464,12 @@ def expired_invitation(session: Session, test_organization: Organization, member @pytest.fixture -def used_invitation(session: Session, test_organization: Organization, member_role: Role, non_member_user: User) -> Invitation: +def used_invitation( + session: Session, + test_organization: Organization, + member_role: Role, + non_member_user: User, +) -> Invitation: """Creates an Invitation that has already been used.""" # Assert IDs are not None to satisfy type checker assert test_organization.id is not None @@ -453,7 +483,7 @@ def used_invitation(session: Session, test_organization: Organization, member_ro expires_at=datetime.now(UTC) + timedelta(days=7), used=True, accepted_at=datetime.now(UTC), - accepted_by_user_id=non_member_user.id + accepted_by_user_id=non_member_user.id, ) session.add(invitation) session.commit() @@ -465,8 +495,7 @@ def used_invitation(session: Session, test_organization: Organization, member_ro def existing_invitee_account(session: Session) -> Account: """Creates an Account for invitee@example.com with a primary AccountEmail.""" account = Account( - email="invitee@example.com", - hashed_password=get_password_hash("Invitee123!@#") + email="invitee@example.com", hashed_password=get_password_hash("Invitee123!@#") ) session.add(account) session.flush() @@ -486,10 +515,7 @@ def existing_invitee_account(session: Session) -> Account: @pytest.fixture def existing_invitee_user(session: Session, existing_invitee_account: Account) -> User: """Creates a User linked to existing_invitee_account.""" - user = User( - name="Invitee User", - account_id=existing_invitee_account.id - ) + user = User(name="Invitee User", account_id=existing_invitee_account.id) session.add(user) session.commit() session.refresh(user) @@ -499,18 +525,24 @@ def existing_invitee_user(session: Session, existing_invitee_account: Account) - @pytest.fixture -def auth_client_invitee(session: Session, existing_invitee_user: User) -> Generator[TestClient, None, None]: +def auth_client_invitee( + session: Session, existing_invitee_user: User +) -> Generator[TestClient, None, None]: """Provides a TestClient authenticated as the existing_invitee_user.""" client = TestClient(app, follow_redirects=False) # Initialize tokens access_token = "" refresh_token = "" - + # Create and set valid tokens if existing_invitee_user.account: access_token = create_access_token({"sub": existing_invitee_user.account.email}) - refresh_token = create_tracked_refresh_token(existing_invitee_user.account.id, existing_invitee_user.account.email, session) + refresh_token = create_tracked_refresh_token( + existing_invitee_user.account.id, + existing_invitee_user.account.email, + session, + ) session.commit() client.cookies.set("access_token", access_token) @@ -518,8 +550,10 @@ def auth_client_invitee(session: Session, existing_invitee_user: User) -> Genera yield client + # --- Email Mocking Fixtures --- + @pytest.fixture def mock_email_response(): """ @@ -537,11 +571,12 @@ def mock_email_response(): "bcc": [], "cc": [], "reply_to": [], - "last_event": "delivered" + "last_event": "delivered", } # Ensure resend is imported import resend - return resend.Email(**email_data) + + return cast(resend.Email, email_data) @pytest.fixture @@ -551,11 +586,14 @@ def mock_resend_send(mock_email_response): """ # Ensure patch and resend are imported from unittest.mock import patch - with patch('resend.Emails.send', return_value=mock_email_response) as mock: + + with patch("resend.Emails.send", return_value=mock_email_response) as mock: yield mock + # --- HTMX Test Helpers --- + def htmx_headers() -> dict: """Headers that simulate an HTMX request.""" return {"HX-Request": "true", "HX-Current-URL": "http://testserver/"} @@ -563,7 +601,4 @@ def htmx_headers() -> dict: def is_html_partial(response) -> bool: """True if response is a 200 HTML fragment (not a full page).""" - return ( - response.status_code == 200 - and "" not in response.text - ) + return response.status_code == 200 and "" not in response.text diff --git a/tests/routers/core/test_account.py b/tests/routers/core/test_account.py index a60f459..f6284a4 100644 --- a/tests/routers/core/test_account.py +++ b/tests/routers/core/test_account.py @@ -8,7 +8,15 @@ from sqlalchemy import inspect from main import app -from utils.core.models import User, AccountEmail, AccountRecoveryToken, EmailVerificationToken, PasswordResetToken, RefreshToken, Account +from utils.core.models import ( + User, + AccountEmail, + AccountRecoveryToken, + EmailVerificationToken, + PasswordResetToken, + RefreshToken, + Account, +) from utils.core.auth import ( create_access_token, create_recovery_token, @@ -17,7 +25,7 @@ generate_recovery_url, verify_password, validate_token, - get_password_hash + get_password_hash, ) from utils.core.rate_limit import ( login_ip_limiter, @@ -41,6 +49,7 @@ def _reset_rate_limiters(): ): limiter._attempts.clear() + # --- API Endpoint Tests --- @@ -49,7 +58,7 @@ def test_register_endpoint(unauth_client: TestClient, session: Session): inspector = inspect(session.bind) if inspector: # Add null check print("Tables in the database:", inspector.get_table_names()) - + # Create a mock register response response = unauth_client.post( app.url_path_for("register"), @@ -57,26 +66,30 @@ def test_register_endpoint(unauth_client: TestClient, session: Session): "name": "New User", "email": "new@example.com", "password": "NewPass123!@#", - "confirm_password": "NewPass123!@#" + "confirm_password": "NewPass123!@#", }, ) # Just check the response status code assert response.status_code == 303 assert response.headers["location"] == str(app.url_path_for("read_dashboard")) - + # Verify the account was created - account = session.exec(select(Account).where(Account.email == "new@example.com")).first() + account = session.exec( + select(Account).where(Account.email == "new@example.com") + ).first() assert account is not None assert verify_password("NewPass123!@#", account.hashed_password) - + # Verify the user was created and linked to the account user = session.exec(select(User).where(User.account_id == account.id)).first() assert user is not None assert user.name == "New User" -def test_register_creates_account_email_row(unauth_client: TestClient, session: Session): +def test_register_creates_account_email_row( + unauth_client: TestClient, session: Session +): """Test that registration creates an AccountEmail row with is_primary=True and verified=True.""" response = unauth_client.post( app.url_path_for("register"), @@ -84,12 +97,14 @@ def test_register_creates_account_email_row(unauth_client: TestClient, session: "name": "Email Test User", "email": "emailtest@example.com", "password": "NewPass123!@#", - "confirm_password": "NewPass123!@#" + "confirm_password": "NewPass123!@#", }, ) assert response.status_code == 303 - account = session.exec(select(Account).where(Account.email == "emailtest@example.com")).first() + account = session.exec( + select(Account).where(Account.email == "emailtest@example.com") + ).first() assert account is not None account_email = session.exec( @@ -105,10 +120,7 @@ def test_register_creates_account_email_row(unauth_client: TestClient, session: def test_login_endpoint(unauth_client: TestClient, test_account: Account): response = unauth_client.post( app.url_path_for("login"), - data={ - "email": test_account.email, - "password": "Test123!@#" - }, + data={"email": test_account.email, "password": "Test123!@#"}, ) assert response.status_code == 303 assert response.headers["location"] == str(app.url_path_for("read_dashboard")) @@ -122,8 +134,7 @@ def test_login_endpoint(unauth_client: TestClient, test_account: Account): def test_refresh_token_endpoint(auth_client: TestClient, test_account: Account): # Override just the access token to be expired, keeping the valid refresh token expired_access_token = create_access_token( - {"sub": test_account.email}, - timedelta(minutes=-10) + {"sub": test_account.email}, timedelta(minutes=-10) ) auth_client.cookies.set("access_token", expired_access_token) @@ -150,7 +161,9 @@ def test_refresh_token_endpoint(auth_client: TestClient, test_account: Account): assert decoded["sub"] == test_account.email -def test_password_reset_flow(unauth_client: TestClient, session: Session, test_account: Account, mock_resend_send): +def test_password_reset_flow( + unauth_client: TestClient, session: Session, test_account: Account, mock_resend_send +): # Test forgot password request response = unauth_client.post( app.url_path_for("forgot_password"), @@ -177,16 +190,19 @@ def test_password_reset_flow(unauth_client: TestClient, session: Session, test_a assert "reset_password" in call_args["html"] # Verify reset token was created - reset_token = session.exec(select(PasswordResetToken) - .where(PasswordResetToken.account_id == test_account.id)).first() + reset_token = session.exec( + select(PasswordResetToken).where( + PasswordResetToken.account_id == test_account.id + ) + ).first() assert reset_token is not None assert not reset_token.used - + # Update password and mark token as used directly in the database test_account.hashed_password = get_password_hash("NewPass123!@#") reset_token.used = True session.commit() - + # Verify password was updated and token was marked as used session.refresh(test_account) session.refresh(reset_token) @@ -204,9 +220,12 @@ def test_logout_endpoint(auth_client: TestClient): # Check for cookie deletion in headers cookie_headers = response.headers.get_list("set-cookie") assert any( - "access_token=" in cookie and "Max-Age=0" in cookie for cookie in cookie_headers) + "access_token=" in cookie and "Max-Age=0" in cookie for cookie in cookie_headers + ) assert any( - "refresh_token=" in cookie and "Max-Age=0" in cookie for cookie in cookie_headers) + "refresh_token=" in cookie and "Max-Age=0" in cookie + for cookie in cookie_headers + ) # --- Error Case Tests --- @@ -221,8 +240,12 @@ def test_register_page_shows_password_requirements(unauth_client: TestClient): assert "8" in html, "Page should mention minimum 8 characters" assert "uppercase" in html.lower(), "Page should mention uppercase requirement" assert "lowercase" in html.lower(), "Page should mention lowercase requirement" - assert "number" in html.lower() or "digit" in html.lower(), "Page should mention digit requirement" - assert "special" in html.lower(), "Page should mention special character requirement" + assert "number" in html.lower() or "digit" in html.lower(), ( + "Page should mention digit requirement" + ) + assert "special" in html.lower(), ( + "Page should mention special character requirement" + ) def test_register_page_confirm_password_has_autocomplete(unauth_client: TestClient): @@ -234,13 +257,17 @@ def test_register_page_confirm_password_has_autocomplete(unauth_client: TestClie assert 'id="confirm_password"' in html # Find the confirm_password input and check it has autocomplete="new-password" import re + confirm_input = re.search(r']*id="confirm_password"[^>]*>', html) assert confirm_input is not None - assert 'autocomplete="new-password"' in confirm_input.group(0), \ + assert 'autocomplete="new-password"' in confirm_input.group(0), ( "confirm_password field must have autocomplete='new-password' for Chrome autofill" + ) -def test_register_weak_password_error_restates_requirements(unauth_client: TestClient, session: Session): +def test_register_weak_password_error_restates_requirements( + unauth_client: TestClient, session: Session +): """Issue #156: Error toast for weak password must restate the security policy requirements.""" response = unauth_client.post( app.url_path_for("register"), @@ -248,20 +275,24 @@ def test_register_weak_password_error_restates_requirements(unauth_client: TestC "name": "Test User", "email": "weak@example.com", "password": "weak", - "confirm_password": "weak" + "confirm_password": "weak", }, ) assert response.status_code == 422 text = response.text # The error message must include the actual requirements, not just a generic message assert "8" in text, "Error should mention minimum 8 characters" - assert "uppercase" in text.lower() or "upper" in text.lower(), \ + assert "uppercase" in text.lower() or "upper" in text.lower(), ( "Error should mention uppercase requirement" - assert "lowercase" in text.lower() or "lower" in text.lower(), \ + ) + assert "lowercase" in text.lower() or "lower" in text.lower(), ( "Error should mention lowercase requirement" + ) -def test_register_weak_password_htmx_error_restates_requirements(unauth_client: TestClient, session: Session): +def test_register_weak_password_htmx_error_restates_requirements( + unauth_client: TestClient, session: Session +): """Issue #156: HTMX error toast for weak password must restate the security policy requirements.""" response = unauth_client.post( app.url_path_for("register"), @@ -269,7 +300,7 @@ def test_register_weak_password_htmx_error_restates_requirements(unauth_client: "name": "Test User", "email": "weak@example.com", "password": "weak", - "confirm_password": "weak" + "confirm_password": "weak", }, headers={"HX-Request": "true"}, ) @@ -277,10 +308,12 @@ def test_register_weak_password_htmx_error_restates_requirements(unauth_client: text = response.text # The toast message must include the actual requirements assert "8" in text, "Toast should mention minimum 8 characters" - assert "uppercase" in text.lower() or "upper" in text.lower(), \ + assert "uppercase" in text.lower() or "upper" in text.lower(), ( "Toast should mention uppercase requirement" - assert "lowercase" in text.lower() or "lower" in text.lower(), \ + ) + assert "lowercase" in text.lower() or "lower" in text.lower(), ( "Toast should mention lowercase requirement" + ) def test_register_with_existing_email(unauth_client: TestClient, test_account: Account): @@ -290,32 +323,33 @@ def test_register_with_existing_email(unauth_client: TestClient, test_account: A "name": "Another User", "email": test_account.email, "password": "Test123!@#", - "confirm_password": "Test123!@#" - } + "confirm_password": "Test123!@#", + }, ) assert response.status_code == 409 -def test_login_with_invalid_credentials(unauth_client: TestClient, test_account: Account): +def test_login_with_invalid_credentials( + unauth_client: TestClient, test_account: Account +): response = unauth_client.post( app.url_path_for("login"), - data={ - "email": test_account.email, - "password": "WrongPass123!@#" - } + data={"email": test_account.email, "password": "WrongPass123!@#"}, ) assert response.status_code == 401 -def test_password_reset_with_invalid_token(unauth_client: TestClient, test_account: Account): +def test_password_reset_with_invalid_token( + unauth_client: TestClient, test_account: Account +): response = unauth_client.post( app.url_path_for("reset_password"), data={ "email": test_account.email, "token": "invalid_token", "password": "NewPass123!@#", - "confirm_password": "NewPass123!@#" - } + "confirm_password": "NewPass123!@#", + }, ) assert response.status_code == 401 # Unauthorized for invalid token assert app.url_path_for("read_login") not in response.headers.get("location", "") @@ -345,7 +379,7 @@ def test_password_reset_auto_logs_in_and_shows_flash( "token": reset_token.token, "password": "NewPass123!@#", "confirm_password": "NewPass123!@#", - } + }, ) assert response.status_code == 303 @@ -393,13 +427,17 @@ def test_password_reset_after_recovery_auto_logs_in( session.add(user) attacker_account_email = AccountEmail( - account_id=account.id, email=attacker_email, - is_primary=True, verified=True, verified_at=datetime.now(UTC), + account_id=account.id, + email=attacker_email, + is_primary=True, + verified=True, + verified_at=datetime.now(UTC), ) session.add(attacker_account_email) recovery_token = AccountRecoveryToken( - account_id=account.id, email=original_email, + account_id=account.id, + email=original_email, ) session.add(recovery_token) session.commit() @@ -428,7 +466,7 @@ def test_password_reset_after_recovery_auto_logs_in( "token": reset_token_value, "password": "NewSecure123!@#", "confirm_password": "NewSecure123!@#", - } + }, ) assert reset_response.status_code == 303 @@ -445,7 +483,9 @@ def test_password_reset_after_recovery_auto_logs_in( assert any("flash_message=" in c for c in cookie_headers) -def test_password_reset_email_url(unauth_client: TestClient, session: Session, test_account: Account, mock_resend_send): +def test_password_reset_email_url( + unauth_client: TestClient, session: Session, test_account: Account, mock_resend_send +): """ Tests that the password reset email contains a properly formatted reset URL. """ @@ -457,8 +497,11 @@ def test_password_reset_email_url(unauth_client: TestClient, session: Session, t assert response.headers["location"] == "/forgot_password?show_form=false" # Get the reset token from the database - reset_token = session.exec(select(PasswordResetToken) - .where(PasswordResetToken.account_id == test_account.id)).first() + reset_token = session.exec( + select(PasswordResetToken).where( + PasswordResetToken.account_id == test_account.id + ) + ).first() assert reset_token is not None # Get the actual path from the FastAPI app @@ -471,6 +514,7 @@ def test_password_reset_email_url(unauth_client: TestClient, session: Session, t # Extract URL from HTML import re + url_match = re.search(r']*href=[\'"]([^\'"]*)[\'"]', html_content) assert url_match is not None reset_url = unescape(url_match.group(1)) @@ -506,7 +550,9 @@ def test_forgot_password_does_not_send_second_email_while_token_is_active( assert second_response.headers["location"] == "/forgot_password?show_form=false" tokens = session.exec( - select(PasswordResetToken).where(PasswordResetToken.account_id == test_account.id) + select(PasswordResetToken).where( + PasswordResetToken.account_id == test_account.id + ) ).all() assert len(tokens) == 1 assert mock_resend_send.call_count == 1 @@ -546,7 +592,9 @@ def test_login_email_rate_limit(unauth_client: TestClient, test_account: Account assert response.status_code == 429 -def test_login_success_resets_email_limiter(unauth_client: TestClient, test_account: Account): +def test_login_success_resets_email_limiter( + unauth_client: TestClient, test_account: Account +): """Successful login resets the per-email rate limiter.""" # Use up all but one email attempt for _ in range(login_email_limiter.max_attempts - 1): @@ -554,7 +602,10 @@ def test_login_success_resets_email_limiter(unauth_client: TestClient, test_acco app.url_path_for("login"), data={"email": test_account.email, "password": "WrongPass123!@#"}, ) - assert login_email_limiter.remaining(f"email:{test_account.email.lower().strip()}") == 1 + assert ( + login_email_limiter.remaining(f"email:{test_account.email.lower().strip()}") + == 1 + ) # Successful login should reset the counter response = unauth_client.post( @@ -565,7 +616,10 @@ def test_login_success_resets_email_limiter(unauth_client: TestClient, test_acco assert response.headers["location"] == str(app.url_path_for("read_dashboard")) # Verify the limiter was reset — full allowance available - assert login_email_limiter.remaining(f"email:{test_account.email.lower().strip()}") == login_email_limiter.max_attempts + assert ( + login_email_limiter.remaining(f"email:{test_account.email.lower().strip()}") + == login_email_limiter.max_attempts + ) def test_register_ip_rate_limit(unauth_client: TestClient, session: Session): @@ -608,7 +662,9 @@ def test_forgot_password_ip_rate_limit(unauth_client: TestClient): assert response.status_code == 429 -def test_forgot_password_email_rate_limit(unauth_client: TestClient, test_account: Account, mock_resend_send): +def test_forgot_password_email_rate_limit( + unauth_client: TestClient, test_account: Account, mock_resend_send +): """Forgot password returns 429 after exceeding per-email rate limit.""" for _ in range(forgot_password_email_limiter.max_attempts): unauth_client.post( @@ -626,7 +682,9 @@ def test_forgot_password_email_rate_limit(unauth_client: TestClient, test_accoun # --- Refresh Token Security Tests --- -def test_register_creates_tracked_refresh_token(unauth_client: TestClient, session: Session): +def test_register_creates_tracked_refresh_token( + unauth_client: TestClient, session: Session +): """Registration creates a RefreshToken record in the database.""" response = unauth_client.post( app.url_path_for("register"), @@ -634,12 +692,14 @@ def test_register_creates_tracked_refresh_token(unauth_client: TestClient, sessi "name": "Token User", "email": "tokenuser@example.com", "password": "Token123!@#", - "confirm_password": "Token123!@#" + "confirm_password": "Token123!@#", }, ) assert response.status_code == 303 - account = session.exec(select(Account).where(Account.email == "tokenuser@example.com")).first() + account = session.exec( + select(Account).where(Account.email == "tokenuser@example.com") + ).first() assert account is not None db_tokens = session.exec( @@ -649,7 +709,9 @@ def test_register_creates_tracked_refresh_token(unauth_client: TestClient, sessi assert db_tokens[0].revoked is False -def test_login_creates_tracked_refresh_token(unauth_client: TestClient, session: Session, test_account: Account, test_user: User): +def test_login_creates_tracked_refresh_token( + unauth_client: TestClient, session: Session, test_account: Account, test_user: User +): """Login creates a RefreshToken record in the database.""" response = unauth_client.post( app.url_path_for("login"), @@ -664,13 +726,15 @@ def test_login_creates_tracked_refresh_token(unauth_client: TestClient, session: assert any(not t.revoked for t in db_tokens) -def test_logout_revokes_refresh_token(auth_client: TestClient, session: Session, test_account: Account): +def test_logout_revokes_refresh_token( + auth_client: TestClient, session: Session, test_account: Account +): """Logout revokes the refresh token server-side.""" # Get existing tokens before logout db_tokens_before = session.exec( select(RefreshToken).where( RefreshToken.account_id == test_account.id, - RefreshToken.revoked == False # noqa: E712 + RefreshToken.revoked == False, # noqa: E712 ) ).all() assert len(db_tokens_before) >= 1 @@ -682,13 +746,15 @@ def test_logout_revokes_refresh_token(auth_client: TestClient, session: Session, active_tokens = session.exec( select(RefreshToken).where( RefreshToken.account_id == test_account.id, - RefreshToken.revoked == False # noqa: E712 + RefreshToken.revoked == False, # noqa: E712 ) ).all() assert len(active_tokens) == 0 -def test_refresh_endpoint_rotates_token(auth_client: TestClient, session: Session, test_account: Account): +def test_refresh_endpoint_rotates_token( + auth_client: TestClient, session: Session, test_account: Account +): """The /refresh endpoint revokes the old token and issues a new one.""" # Expire the access token so the refresh endpoint works expired_access_token = create_access_token( @@ -700,7 +766,7 @@ def test_refresh_endpoint_rotates_token(auth_client: TestClient, session: Sessio active_before = session.exec( select(RefreshToken).where( RefreshToken.account_id == test_account.id, - RefreshToken.revoked == False # noqa: E712 + RefreshToken.revoked == False, # noqa: E712 ) ).all() assert len(active_before) == 1 @@ -720,7 +786,7 @@ def test_refresh_endpoint_rotates_token(auth_client: TestClient, session: Sessio active_after = session.exec( select(RefreshToken).where( RefreshToken.account_id == test_account.id, - RefreshToken.revoked == False # noqa: E712 + RefreshToken.revoked == False, # noqa: E712 ) ).all() assert len(active_after) == 1 @@ -732,13 +798,15 @@ def test_refresh_reuse_detection_revokes_all_tokens( ): """Replaying a revoked refresh token revokes ALL tokens for that account.""" # Create a tracked refresh token and immediately revoke it (simulating prior use) - refresh_jwt = create_tracked_refresh_token(test_account.id, test_account.email, session) + refresh_jwt = create_tracked_refresh_token( + test_account.id, test_account.email, session + ) session.commit() db_token = session.exec( select(RefreshToken).where( RefreshToken.account_id == test_account.id, - RefreshToken.revoked == False # noqa: E712 + RefreshToken.revoked == False, # noqa: E712 ) ).first() assert db_token is not None @@ -763,7 +831,7 @@ def test_refresh_reuse_detection_revokes_all_tokens( active_tokens = session.exec( select(RefreshToken).where( RefreshToken.account_id == test_account.id, - RefreshToken.revoked == False # noqa: E712 + RefreshToken.revoked == False, # noqa: E712 ) ).all() assert len(active_tokens) == 0 @@ -774,11 +842,12 @@ def test_legacy_refresh_token_without_jti_rejected( ): """A refresh token without a JTI (pre-migration) is rejected.""" import uuid + # Create a legacy-style token without jti by using create_refresh_token with a jti # but NOT storing it in the DB — simulating a pre-migration token legacy_token = create_refresh_token( data={"sub": test_account.email}, - jti=str(uuid.uuid4()) # has jti in JWT but no DB record + jti=str(uuid.uuid4()), # has jti in JWT but no DB record ) client = TestClient(app, follow_redirects=False) @@ -796,7 +865,9 @@ def test_automatic_token_refresh_via_dependency( ): """When access token expires, the dependency auto-refreshes using the refresh token.""" # Create a tracked refresh token - refresh_jwt = create_tracked_refresh_token(test_account.id, test_account.email, session) + refresh_jwt = create_tracked_refresh_token( + test_account.id, test_account.email, session + ) session.commit() # Create an expired access token @@ -823,7 +894,7 @@ def test_automatic_token_refresh_via_dependency( active_tokens = session.exec( select(RefreshToken).where( RefreshToken.account_id == test_account.id, - RefreshToken.revoked == False # noqa: E712 + RefreshToken.revoked == False, # noqa: E712 ) ).all() # Should have exactly 1 active token (the new one) @@ -834,7 +905,11 @@ def test_automatic_token_refresh_via_dependency( def test_add_email_sends_verification( - auth_client: TestClient, test_account: Account, test_account_email, session: Session, mock_resend_send + auth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, + mock_resend_send, ): """Test that POST /account/emails/add creates a verification token and sends email.""" response = auth_client.post( @@ -868,8 +943,10 @@ def test_add_email_already_registered_returns_409( session.add(other_account) session.commit() other_email = AccountEmail( - account_id=other_account.id, email="taken@example.com", - is_primary=True, verified=True, + account_id=other_account.id, + email="taken@example.com", + is_primary=True, + verified=True, ) session.add(other_email) session.commit() @@ -898,8 +975,10 @@ def test_add_email_max_limit_returns_400( """Test that adding a 3rd email returns 400 when account already has 2.""" # Add a second email to reach the limit second_email = AccountEmail( - account_id=test_account.id, email="second@example.com", - is_primary=False, verified=True, + account_id=test_account.id, + email="second@example.com", + is_primary=False, + verified=True, ) session.add(second_email) session.commit() @@ -921,7 +1000,11 @@ def test_add_email_unauthenticated_redirects(unauth_client: TestClient): def test_add_email_suppresses_duplicate_token( - auth_client: TestClient, test_account: Account, test_account_email, session: Session, mock_resend_send + auth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, + mock_resend_send, ): """Test that a second request for the same email doesn't create a duplicate token.""" # Create an existing unexpired token @@ -953,7 +1036,11 @@ def test_add_email_suppresses_duplicate_token( def test_verify_email_creates_account_email( - unauth_client: TestClient, test_account: Account, test_account_email, session: Session, mock_resend_send + unauth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, + mock_resend_send, ): """Test that clicking a valid verification link creates an AccountEmail row. @@ -1004,10 +1091,14 @@ def test_verify_email_invalid_token_returns_401( def test_verify_email_expired_token_returns_401( - unauth_client: TestClient, test_account: Account, test_account_email, session: Session + unauth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, ): """Test that an expired token returns 401.""" from datetime import timedelta as td + token = EmailVerificationToken( account_id=test_account.id, new_email="expired@example.com", @@ -1024,7 +1115,10 @@ def test_verify_email_expired_token_returns_401( def test_verify_email_used_token_returns_401( - unauth_client: TestClient, test_account: Account, test_account_email, session: Session + unauth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, ): """Test that a used token returns 401.""" token = EmailVerificationToken( @@ -1043,7 +1137,11 @@ def test_verify_email_used_token_returns_401( def test_verify_email_sends_notification_to_primary( - unauth_client: TestClient, test_account: Account, test_account_email, session: Session, mock_resend_send + unauth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, + mock_resend_send, ): """Test that a notification is sent to the primary email after verification.""" token = EmailVerificationToken( @@ -1066,7 +1164,11 @@ def test_verify_email_sends_notification_to_primary( def test_verify_email_unauthenticated_redirects_to_login( - unauth_client: TestClient, test_account: Account, test_account_email, session: Session, mock_resend_send + unauth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, + mock_resend_send, ): """Email verification flow when session has expired or link opened in another browser: @@ -1107,7 +1209,10 @@ def test_verify_email_unauthenticated_redirects_to_login( def test_verify_email_race_condition_email_taken( - unauth_client: TestClient, test_account: Account, test_account_email, session: Session + unauth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, ): """Test that if email was taken between request and verify, return 409.""" token = EmailVerificationToken( @@ -1122,8 +1227,10 @@ def test_verify_email_race_condition_email_taken( session.add(other_account) session.commit() other_email = AccountEmail( - account_id=other_account.id, email="raced@example.com", - is_primary=True, verified=True, + account_id=other_account.id, + email="raced@example.com", + is_primary=True, + verified=True, ) session.add(other_email) session.commit() @@ -1139,12 +1246,19 @@ def test_verify_email_race_condition_email_taken( def test_promote_email_swaps_primary( - auth_client: TestClient, test_account: Account, test_account_email, session: Session, mock_resend_send + auth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, + mock_resend_send, ): """Test that promoting a secondary email swaps primary flags.""" secondary = AccountEmail( - account_id=test_account.id, email="secondary@example.com", - is_primary=False, verified=True, verified_at=datetime.now(UTC), + account_id=test_account.id, + email="secondary@example.com", + is_primary=False, + verified=True, + verified_at=datetime.now(UTC), ) session.add(secondary) session.commit() @@ -1163,12 +1277,19 @@ def test_promote_email_swaps_primary( def test_promote_email_updates_account_email_field( - auth_client: TestClient, test_account: Account, test_account_email, session: Session, mock_resend_send + auth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, + mock_resend_send, ): """Test that Account.email is updated to the new primary.""" secondary = AccountEmail( - account_id=test_account.id, email="newprimary@example.com", - is_primary=False, verified=True, verified_at=datetime.now(UTC), + account_id=test_account.id, + email="newprimary@example.com", + is_primary=False, + verified=True, + verified_at=datetime.now(UTC), ) session.add(secondary) session.commit() @@ -1185,12 +1306,19 @@ def test_promote_email_updates_account_email_field( def test_promote_email_revokes_refresh_tokens( - auth_client: TestClient, test_account: Account, test_account_email, session: Session, mock_resend_send + auth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, + mock_resend_send, ): """Test that all refresh tokens are revoked and new ones issued.""" secondary = AccountEmail( - account_id=test_account.id, email="promoted@example.com", - is_primary=False, verified=True, verified_at=datetime.now(UTC), + account_id=test_account.id, + email="promoted@example.com", + is_primary=False, + verified=True, + verified_at=datetime.now(UTC), ) session.add(secondary) session.commit() @@ -1209,13 +1337,20 @@ def test_promote_email_revokes_refresh_tokens( def test_promote_email_sends_notification_to_old_primary( - auth_client: TestClient, test_account: Account, test_account_email, session: Session, mock_resend_send + auth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, + mock_resend_send, ): """Test that a notification is sent to the old primary email.""" old_primary_email = test_account.email secondary = AccountEmail( - account_id=test_account.id, email="notified@example.com", - is_primary=False, verified=True, verified_at=datetime.now(UTC), + account_id=test_account.id, + email="notified@example.com", + is_primary=False, + verified=True, + verified_at=datetime.now(UTC), ) session.add(secondary) session.commit() @@ -1237,8 +1372,10 @@ def test_promote_email_unverified_fails_400( ): """Test that promoting an unverified email returns 400.""" unverified = AccountEmail( - account_id=test_account.id, email="unverified@example.com", - is_primary=False, verified=False, + account_id=test_account.id, + email="unverified@example.com", + is_primary=False, + verified=False, ) session.add(unverified) session.commit() @@ -1259,8 +1396,10 @@ def test_promote_email_not_owned_returns_404( session.add(other_account) session.commit() other_email = AccountEmail( - account_id=other_account.id, email="notmine@example.com", - is_primary=True, verified=True, + account_id=other_account.id, + email="notmine@example.com", + is_primary=True, + verified=True, ) session.add(other_email) session.commit() @@ -1289,12 +1428,19 @@ def test_promote_email_already_primary_is_noop( def test_remove_secondary_email_succeeds( - auth_client: TestClient, test_account: Account, test_account_email, session: Session, mock_resend_send + auth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, + mock_resend_send, ): """Test that removing a secondary email deletes the AccountEmail row.""" secondary = AccountEmail( - account_id=test_account.id, email="removeme@example.com", - is_primary=False, verified=True, verified_at=datetime.now(UTC), + account_id=test_account.id, + email="removeme@example.com", + is_primary=False, + verified=True, + verified_at=datetime.now(UTC), ) session.add(secondary) session.commit() @@ -1333,8 +1479,10 @@ def test_remove_email_not_owned_returns_404( session.add(other_account) session.commit() other_email = AccountEmail( - account_id=other_account.id, email="notmine2@example.com", - is_primary=True, verified=True, + account_id=other_account.id, + email="notmine2@example.com", + is_primary=True, + verified=True, ) session.add(other_email) session.commit() @@ -1348,12 +1496,19 @@ def test_remove_email_not_owned_returns_404( def test_remove_email_sends_notification( - auth_client: TestClient, test_account: Account, test_account_email, session: Session, mock_resend_send + auth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, + mock_resend_send, ): """Test that a notification is sent to the removed email address.""" secondary = AccountEmail( - account_id=test_account.id, email="goodbye@example.com", - is_primary=False, verified=True, verified_at=datetime.now(UTC), + account_id=test_account.id, + email="goodbye@example.com", + is_primary=False, + verified=True, + verified_at=datetime.now(UTC), ) session.add(secondary) session.commit() @@ -1387,8 +1542,11 @@ def test_profile_shows_all_emails( ): """Test that profile page shows all email addresses.""" secondary = AccountEmail( - account_id=test_account.id, email="profile-secondary@example.com", - is_primary=False, verified=True, verified_at=datetime.now(UTC), + account_id=test_account.id, + email="profile-secondary@example.com", + is_primary=False, + verified=True, + verified_at=datetime.now(UTC), ) session.add(secondary) session.commit() @@ -1422,8 +1580,10 @@ def test_profile_hides_add_form_at_limit( ): """Test that add email form is hidden when at the limit.""" secondary = AccountEmail( - account_id=test_account.id, email="limit@example.com", - is_primary=False, verified=True, + account_id=test_account.id, + email="limit@example.com", + is_primary=False, + verified=True, ) session.add(secondary) session.commit() @@ -1435,10 +1595,15 @@ def test_profile_hides_add_form_at_limit( def test_add_email_htmx_triggers_form_reset( - auth_client: TestClient, test_account: Account, test_account_email, session: Session, mock_resend_send + auth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, + mock_resend_send, ): """HTMX add-email response should include HX-Trigger to reset the form.""" from tests.conftest import htmx_headers + response = auth_client.post( app.url_path_for("add_email"), data={"new_email": "reset-test@example.com"}, @@ -1457,16 +1622,22 @@ def test_profile_emails_ordered_primary_first( even when the primary row has a higher ID (was created after the secondary).""" # Create secondary FIRST so it gets a lower ID secondary = AccountEmail( - account_id=test_account.id, email="order-secondary-unique@example.com", - is_primary=False, verified=True, verified_at=datetime.now(UTC), + account_id=test_account.id, + email="order-secondary-unique@example.com", + is_primary=False, + verified=True, + verified_at=datetime.now(UTC), ) session.add(secondary) session.commit() # Create primary SECOND so it gets a higher ID primary = AccountEmail( - account_id=test_account.id, email="order-primary-unique@example.com", - is_primary=True, verified=True, verified_at=datetime.now(UTC), + account_id=test_account.id, + email="order-primary-unique@example.com", + is_primary=True, + verified=True, + verified_at=datetime.now(UTC), ) session.add(primary) session.commit() @@ -1475,18 +1646,27 @@ def test_profile_emails_ordered_primary_first( assert response.status_code == 200 primary_pos = response.text.index("order-primary-unique@example.com") secondary_pos = response.text.index("order-secondary-unique@example.com") - assert primary_pos < secondary_pos, "Primary email should appear before secondary emails" + assert primary_pos < secondary_pos, ( + "Primary email should appear before secondary emails" + ) def test_profile_emails_ordered_primary_first_after_promote( - auth_client: TestClient, test_account: Account, test_account_email, session: Session, mock_resend_send + auth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, + mock_resend_send, ): """After promoting a secondary email, it should appear first on the profile page, even though the original primary row has a lower ID.""" # Secondary gets a higher ID than the existing primary (test_account_email) secondary = AccountEmail( - account_id=test_account.id, email="promote-target-unique@example.com", - is_primary=False, verified=True, verified_at=datetime.now(UTC), + account_id=test_account.id, + email="promote-target-unique@example.com", + is_primary=False, + verified=True, + verified_at=datetime.now(UTC), ) session.add(secondary) session.commit() @@ -1504,10 +1684,12 @@ def test_profile_emails_ordered_primary_first_after_promote( assert response.status_code == 200 # Search within the email list section only to avoid matching navbar/header - email_section = response.text[response.text.index("Email Addresses"):] + email_section = response.text[response.text.index("Email Addresses") :] new_primary_pos = email_section.index("promote-target-unique@example.com") old_primary_pos = email_section.index(test_account_email.email) - assert new_primary_pos < old_primary_pos, "Newly promoted primary email should appear first" + assert new_primary_pos < old_primary_pos, ( + "Newly promoted primary email should appear first" + ) # --- Account Recovery Token Helper Tests --- @@ -1520,9 +1702,7 @@ def test_generate_recovery_url(monkeypatch): assert url == "https://example.com/account/recover?token=abc123" -def test_create_recovery_token( - test_account: Account, session: Session -): +def test_create_recovery_token(test_account: Account, session: Session): """Test that create_recovery_token creates a DB row and returns the token string.""" token_str = create_recovery_token(test_account.id, "victim@example.com", session) session.commit() @@ -1536,12 +1716,12 @@ def test_create_recovery_token( assert db_token.account_id == test_account.id assert db_token.email == "victim@example.com" assert db_token.used is False - assert db_token.expires_at.replace(tzinfo=UTC) > datetime.now(UTC) + timedelta(days=6) + assert db_token.expires_at.replace(tzinfo=UTC) > datetime.now(UTC) + timedelta( + days=6 + ) -def test_create_recovery_token_deduplicates( - test_account: Account, session: Session -): +def test_create_recovery_token_deduplicates(test_account: Account, session: Session): """Test that create_recovery_token returns existing token if unexpired one exists.""" token1 = create_recovery_token(test_account.id, "victim@example.com", session) session.commit() @@ -1550,12 +1730,14 @@ def test_create_recovery_token_deduplicates( assert token1 == token2 # Only one token in DB - count = len(session.exec( - select(AccountRecoveryToken).where( - AccountRecoveryToken.account_id == test_account.id, - AccountRecoveryToken.email == "victim@example.com", - ) - ).all()) + count = len( + session.exec( + select(AccountRecoveryToken).where( + AccountRecoveryToken.account_id == test_account.id, + AccountRecoveryToken.email == "victim@example.com", + ) + ).all() + ) assert count == 1 @@ -1567,12 +1749,14 @@ def test_send_primary_email_changed_notification_includes_recovery_url( ): """Test that primary email changed notification includes recovery URL in HTML.""" from utils.core.auth import send_primary_email_changed_notification + monkeypatch.setenv("EMAIL_FROM", "noreply@test.com") monkeypatch.setenv("RESEND_API_KEY", "test_key") send_primary_email_changed_notification( - "old@example.com", "new@example.com", - recovery_url="https://example.com/account/recover?token=abc123" + "old@example.com", + "new@example.com", + recovery_url="https://example.com/account/recover?token=abc123", ) mock_resend_send.assert_called_once() @@ -1582,13 +1766,20 @@ def test_send_primary_email_changed_notification_includes_recovery_url( def test_promote_email_creates_recovery_token( - auth_client: TestClient, test_account: Account, test_account_email, session: Session, mock_resend_send + auth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, + mock_resend_send, ): """Test that promoting an email creates an AccountRecoveryToken for the old primary.""" old_primary_email = test_account.email secondary = AccountEmail( - account_id=test_account.id, email="attacker@example.com", - is_primary=False, verified=True, verified_at=datetime.now(UTC), + account_id=test_account.id, + email="attacker@example.com", + is_primary=False, + verified=True, + verified_at=datetime.now(UTC), ) session.add(secondary) session.commit() @@ -1607,12 +1798,19 @@ def test_promote_email_creates_recovery_token( def test_promote_email_notification_contains_recovery_url( - auth_client: TestClient, test_account: Account, test_account_email, session: Session, mock_resend_send + auth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, + mock_resend_send, ): """Test that the promote notification email contains a recovery URL.""" secondary = AccountEmail( - account_id=test_account.id, email="attacker2@example.com", - is_primary=False, verified=True, verified_at=datetime.now(UTC), + account_id=test_account.id, + email="attacker2@example.com", + is_primary=False, + verified=True, + verified_at=datetime.now(UTC), ) session.add(secondary) session.commit() @@ -1626,12 +1824,19 @@ def test_promote_email_notification_contains_recovery_url( def test_remove_email_creates_recovery_token( - auth_client: TestClient, test_account: Account, test_account_email, session: Session, mock_resend_send + auth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, + mock_resend_send, ): """Test that removing an email creates an AccountRecoveryToken for the removed email.""" secondary = AccountEmail( - account_id=test_account.id, email="removed@example.com", - is_primary=False, verified=True, verified_at=datetime.now(UTC), + account_id=test_account.id, + email="removed@example.com", + is_primary=False, + verified=True, + verified_at=datetime.now(UTC), ) session.add(secondary) session.commit() @@ -1650,12 +1855,19 @@ def test_remove_email_creates_recovery_token( def test_remove_email_notification_contains_recovery_url( - auth_client: TestClient, test_account: Account, test_account_email, session: Session, mock_resend_send + auth_client: TestClient, + test_account: Account, + test_account_email, + session: Session, + mock_resend_send, ): """Test that the remove notification email contains a recovery URL.""" secondary = AccountEmail( - account_id=test_account.id, email="removed2@example.com", - is_primary=False, verified=True, verified_at=datetime.now(UTC), + account_id=test_account.id, + email="removed2@example.com", + is_primary=False, + verified=True, + verified_at=datetime.now(UTC), ) session.add(secondary) session.commit() @@ -1673,12 +1885,13 @@ def test_send_email_removed_notification_includes_recovery_url( ): """Test that email removed notification includes recovery URL in HTML.""" from utils.core.auth import send_email_removed_notification + monkeypatch.setenv("EMAIL_FROM", "noreply@test.com") monkeypatch.setenv("RESEND_API_KEY", "test_key") send_email_removed_notification( "removed@example.com", - recovery_url="https://example.com/account/recover?token=xyz789" + recovery_url="https://example.com/account/recover?token=xyz789", ) mock_resend_send.assert_called_once() @@ -1706,8 +1919,11 @@ def _setup_compromised_account(session: Session) -> tuple: # Attacker's email is now primary attacker_account_email = AccountEmail( - account_id=account.id, email=attacker_email, - is_primary=True, verified=True, verified_at=datetime.now(UTC), + account_id=account.id, + email=attacker_email, + is_primary=True, + verified=True, + verified_at=datetime.now(UTC), ) session.add(attacker_account_email) @@ -1773,7 +1989,9 @@ def test_recover_account_revokes_all_sessions( account, recovery_token, _ = _setup_compromised_account(session) # Create a refresh token for the account - rt = RefreshToken(account_id=account.id, expires_at=datetime.now(UTC) + timedelta(days=30)) + rt = RefreshToken( + account_id=account.id, expires_at=datetime.now(UTC) + timedelta(days=30) + ) session.add(rt) session.commit() @@ -1822,9 +2040,7 @@ def test_recover_account_redirects_to_reset_password( assert f"email={original_email}" in location -def test_recover_account_marks_token_used( - unauth_client: TestClient, session: Session -): +def test_recover_account_marks_token_used(unauth_client: TestClient, session: Session): """Test that the recovery token is marked as used after recovery.""" account, recovery_token, _ = _setup_compromised_account(session) @@ -1852,9 +2068,7 @@ def test_recover_account_expired_token_fails( assert response.status_code == 401 -def test_recover_account_used_token_fails( - unauth_client: TestClient, session: Session -): +def test_recover_account_used_token_fails(unauth_client: TestClient, session: Session): """Test that a used recovery token fails.""" account, recovery_token, _ = _setup_compromised_account(session) recovery_token.used = True @@ -1937,12 +2151,18 @@ def test_recover_account_when_victim_email_still_exists_as_account_email( # After a primary swap: attacker email is primary, victim email is still present as secondary attacker_account_email = AccountEmail( - account_id=account.id, email=attacker_email, - is_primary=True, verified=True, verified_at=datetime.now(UTC), + account_id=account.id, + email=attacker_email, + is_primary=True, + verified=True, + verified_at=datetime.now(UTC), ) victim_account_email = AccountEmail( - account_id=account.id, email=original_email, - is_primary=False, verified=True, verified_at=datetime.now(UTC), + account_id=account.id, + email=original_email, + is_primary=False, + verified=True, + verified_at=datetime.now(UTC), ) session.add(attacker_account_email) session.add(victim_account_email) diff --git a/tests/routers/core/test_dashboard.py b/tests/routers/core/test_dashboard.py index 0c59980..d5cb828 100644 --- a/tests/routers/core/test_dashboard.py +++ b/tests/routers/core/test_dashboard.py @@ -87,9 +87,7 @@ def test_dashboard_respects_org_cookie( session.commit() # Set the cookie and load dashboard - auth_client_owner.cookies.set( - "selected_organization_id", str(test_organization.id) - ) + auth_client_owner.cookies.set("selected_organization_id", str(test_organization.id)) response = auth_client_owner.get( app.url_path_for("read_dashboard"), follow_redirects=True, @@ -101,9 +99,7 @@ def test_dashboard_respects_org_cookie( def test_dashboard_no_resources(auth_client_owner, test_organization): """Test dashboard with no resources for the selected organization.""" - auth_client_owner.cookies.set( - "selected_organization_id", str(test_organization.id) - ) + auth_client_owner.cookies.set("selected_organization_id", str(test_organization.id)) response = auth_client_owner.get( app.url_path_for("read_dashboard"), follow_redirects=True, diff --git a/tests/routers/core/test_invitation.py b/tests/routers/core/test_invitation.py index 74cd915..7dee11b 100644 --- a/tests/routers/core/test_invitation.py +++ b/tests/routers/core/test_invitation.py @@ -8,6 +8,7 @@ from main import app from utils.core.invitations import generate_invitation_link + @pytest.fixture def invite_user_permission(session: Session) -> Permission: """Get the INVITE_USER permission.""" @@ -26,48 +27,56 @@ def invite_user_permission(session: Session) -> Permission: orgs = session.exec(select(Organization)).all() for org in orgs: assert org.id is not None - owner_role = session.exec(select(Role).where(Role.name == "Owner", Role.organization_id == org.id)).first() + owner_role = session.exec( + select(Role).where(Role.name == "Owner", Role.organization_id == org.id) + ).first() if owner_role and permission not in owner_role.permissions: owner_role.permissions.append(permission) session.add(owner_role) - admin_role = session.exec(select(Role).where(Role.name == "Admin", Role.organization_id == org.id)).first() + admin_role = session.exec( + select(Role).where(Role.name == "Admin", Role.organization_id == org.id) + ).first() if admin_role and permission not in admin_role.permissions: admin_role.permissions.append(permission) session.add(admin_role) - session.commit() # Commit role permission changes + session.commit() # Commit role permission changes - if permission is None: # Re-check in case creation failed silently (shouldn't happen) + if ( + permission is None + ): # Re-check in case creation failed silently (shouldn't happen) raise SetupError("INVITE_USER permission could not be found or created.") return permission + @pytest.fixture -def inviter_user(session: Session, test_user: User, test_organization: Organization, invite_user_permission: Permission) -> User: +def inviter_user( + session: Session, + test_user: User, + test_organization: Organization, + invite_user_permission: Permission, +) -> User: """Create a user with INVITE_USER permission.""" - assert test_organization.id is not None # Ensure org ID is not None + assert test_organization.id is not None # Ensure org ID is not None # Find or create an "Inviter Role" specific for this test setup inviter_role = session.exec( select(Role).where( - Role.name == "Inviter Role", - Role.organization_id == test_organization.id + Role.name == "Inviter Role", Role.organization_id == test_organization.id ) ).first() if not inviter_role: - inviter_role = Role( - name="Inviter Role", - organization_id=test_organization.id - ) + inviter_role = Role(name="Inviter Role", organization_id=test_organization.id) session.add(inviter_role) # Add permission link before committing the role inviter_role.permissions.append(invite_user_permission) - session.commit() # Commit role and permission link - session.refresh(inviter_role) # Refresh to load relationship if needed + session.commit() # Commit role and permission link + session.refresh(inviter_role) # Refresh to load relationship if needed # Check if user already has the role to avoid duplicate entries if inviter_role not in test_user.roles: test_user.roles.append(inviter_role) - session.add(test_user) # Add user again to update relationship + session.add(test_user) # Add user again to update relationship session.commit() session.refresh(test_user) @@ -77,20 +86,31 @@ def inviter_user(session: Session, test_user: User, test_organization: Organizat for r in test_user.roles: session.refresh(r) # Ensure permissions are loaded for the check - if hasattr(r, 'permissions'): - # Check if permissions relation is loaded, might need eager loading strategy - # or explicit refresh like session.refresh(r, attribute_names=['permissions']) - pass # Assume loaded for now - assert any(p.name == ValidPermissions.INVITE_USER for role in test_user.roles if role.organization_id == test_organization.id for p in role.permissions) + if hasattr(r, "permissions"): + # Check if permissions relation is loaded, might need eager loading strategy + # or explicit refresh like session.refresh(r, attribute_names=['permissions']) + pass # Assume loaded for now + assert any( + p.name == ValidPermissions.INVITE_USER + for role in test_user.roles + if role.organization_id == test_organization.id + for p in role.permissions + ) return test_user @pytest.fixture -def existing_invitation(session: Session, test_organization: Organization, inviter_user: User) -> Invitation: +def existing_invitation( + session: Session, test_organization: Organization, inviter_user: User +) -> Invitation: """Create a sample invitation for testing.""" assert test_organization.id is not None # Ensure the Member role exists - member_role = session.exec(select(Role).where(Role.name == "Member", Role.organization_id == test_organization.id)).first() + member_role = session.exec( + select(Role).where( + Role.name == "Member", Role.organization_id == test_organization.id + ) + ).first() if not member_role: member_role = Role(name="Member", organization_id=test_organization.id) session.add(member_role) @@ -103,18 +123,25 @@ def existing_invitation(session: Session, test_organization: Organization, invit role_id=member_role.id, invitee_email="invited@example.com", token="test-token-12345", - expires_at=datetime.now(UTC) + timedelta(days=7) + expires_at=datetime.now(UTC) + timedelta(days=7), ) session.add(invitation) session.commit() session.refresh(invitation) return invitation + @pytest.fixture -def expired_invitation(session: Session, test_organization: Organization, inviter_user: User) -> Invitation: +def expired_invitation( + session: Session, test_organization: Organization, inviter_user: User +) -> Invitation: """Create an expired invitation for testing.""" assert test_organization.id is not None - member_role = session.exec(select(Role).where(Role.name == "Member", Role.organization_id == test_organization.id)).first() + member_role = session.exec( + select(Role).where( + Role.name == "Member", Role.organization_id == test_organization.id + ) + ).first() if not member_role: pytest.fail("Member role not found for expired_invitation fixture") assert member_role.id is not None @@ -124,32 +151,48 @@ def expired_invitation(session: Session, test_organization: Organization, invite role_id=member_role.id, invitee_email="expired@example.com", token="expired-token-12345", - expires_at=datetime.now(UTC) - timedelta(days=1) # Expired yesterday + expires_at=datetime.now(UTC) - timedelta(days=1), # Expired yesterday ) session.add(invitation) session.commit() session.refresh(invitation) return invitation + @pytest.fixture -def used_invitation(session: Session, test_organization: Organization, inviter_user: User, test_user: User) -> Invitation: +def used_invitation( + session: Session, + test_organization: Organization, + inviter_user: User, + test_user: User, +) -> Invitation: """Create a used invitation for testing.""" assert test_organization.id is not None - member_role = session.exec(select(Role).where(Role.name == "Member", Role.organization_id == test_organization.id)).first() + member_role = session.exec( + select(Role).where( + Role.name == "Member", Role.organization_id == test_organization.id + ) + ).first() if not member_role: pytest.fail("Member role not found for used_invitation fixture") assert member_role.id is not None # Create a user account for the accepted_by user if it doesn't exist - accepted_by_account = session.exec(select(Account).where(Account.email == "accepted_by@example.com")).first() + accepted_by_account = session.exec( + select(Account).where(Account.email == "accepted_by@example.com") + ).first() if not accepted_by_account: - accepted_by_account = Account(email="accepted_by@example.com", hashed_password="password") + accepted_by_account = Account( + email="accepted_by@example.com", hashed_password="password" + ) session.add(accepted_by_account) session.commit() session.refresh(accepted_by_account) assert accepted_by_account.id is not None - accepted_by_user = session.exec(select(User).where(User.account_id == accepted_by_account.id)).first() + accepted_by_user = session.exec( + select(User).where(User.account_id == accepted_by_account.id) + ).first() if not accepted_by_user: accepted_by_user = User(name="Accepted User", account_id=accepted_by_account.id) session.add(accepted_by_user) @@ -157,7 +200,6 @@ def used_invitation(session: Session, test_organization: Organization, inviter_u session.refresh(accepted_by_user) assert accepted_by_user.id is not None - invitation = Invitation( organization_id=test_organization.id, role_id=member_role.id, @@ -166,30 +208,51 @@ def used_invitation(session: Session, test_organization: Organization, inviter_u expires_at=datetime.now(UTC) + timedelta(days=7), used=True, accepted_at=datetime.now(UTC), - accepted_by_user_id=accepted_by_user.id # Use a different user than test_user to avoid conflicts + accepted_by_user_id=accepted_by_user.id, # Use a different user than test_user to avoid conflicts ) session.add(invitation) session.commit() session.refresh(invitation) return invitation + # --- Model Tests --- -def test_invitation_is_expired(session: Session, existing_invitation: Invitation, expired_invitation: Invitation): +def test_invitation_is_expired( + session: Session, existing_invitation: Invitation, expired_invitation: Invitation +): """Test the is_expired method correctly identifies expired invitations.""" assert not existing_invitation.is_expired() assert expired_invitation.is_expired() -def test_invitation_is_active(session: Session, existing_invitation: Invitation, expired_invitation: Invitation, used_invitation: Invitation): + +def test_invitation_is_active( + session: Session, + existing_invitation: Invitation, + expired_invitation: Invitation, + used_invitation: Invitation, +): """Test the is_active method correctly identifies active invitations.""" assert existing_invitation.is_active() assert not expired_invitation.is_active() assert not used_invitation.is_active() -def test_get_active_for_org(session: Session, test_organization: Organization, inviter_user: User, existing_invitation: Invitation, expired_invitation: Invitation, used_invitation: Invitation): + +def test_get_active_for_org( + session: Session, + test_organization: Organization, + inviter_user: User, + existing_invitation: Invitation, + expired_invitation: Invitation, + used_invitation: Invitation, +): """Test the get_active_for_org class method returns only active invitations.""" assert test_organization.id is not None # Ensure the Member role exists - member_role = session.exec(select(Role).where(Role.name == "Member", Role.organization_id == test_organization.id)).first() + member_role = session.exec( + select(Role).where( + Role.name == "Member", Role.organization_id == test_organization.id + ) + ).first() if not member_role: pytest.fail("Member role not found in test_get_active_for_org") assert member_role.id is not None @@ -200,18 +263,20 @@ def test_get_active_for_org(session: Session, test_organization: Organization, i role_id=member_role.id, invitee_email="another@example.com", token="another-token-12345", - expires_at=datetime.now(UTC) + timedelta(days=7) + expires_at=datetime.now(UTC) + timedelta(days=7), ) session.add(second_active) # Create an active invitation in a different org other_org = Organization(name="Other Org") session.add(other_org) - session.commit() # Commit to get other_org.id - assert other_org.id is not None # Ensure other_org ID is not None + session.commit() # Commit to get other_org.id + assert other_org.id is not None # Ensure other_org ID is not None # Ensure the Member role exists in the other org too, or create one - other_member_role = session.exec(select(Role).where(Role.name == "Member", Role.organization_id == other_org.id)).first() + other_member_role = session.exec( + select(Role).where(Role.name == "Member", Role.organization_id == other_org.id) + ).first() if not other_member_role: other_member_role = Role(name="Member", organization_id=other_org.id) session.add(other_member_role) @@ -228,7 +293,7 @@ def test_get_active_for_org(session: Session, test_organization: Organization, i role_id=other_member_role.id, invitee_email="other-org@example.com", token="other-org-token-12345", - expires_at=datetime.now(UTC) + timedelta(days=7) + expires_at=datetime.now(UTC) + timedelta(days=7), ) session.add(other_org_invitation) session.commit() @@ -244,6 +309,7 @@ def test_get_active_for_org(session: Session, test_organization: Organization, i assert used_invitation not in active_invitations assert other_org_invitation not in active_invitations + # --- Utility Tests --- def test_generate_invitation_link(): """Test the generate_invitation_link function creates correct URLs.""" @@ -260,12 +326,22 @@ def test_generate_invitation_link(): # --- Create Invitation Endpoint Tests --- # Note: Assumes mock_resend_send fixture is available (moved to conftest.py as suggested) -def test_create_invitation_success(auth_client, inviter_user: User, test_organization: Organization, session: Session, mock_resend_send: MagicMock): +def test_create_invitation_success( + auth_client, + inviter_user: User, + test_organization: Organization, + session: Session, + mock_resend_send: MagicMock, +): """Test successful invitation creation, including email sending.""" invitee_email = "new_invite@example.com" assert test_organization.id is not None # Ensure the Member role exists and get its ID - member_role = session.exec(select(Role).where(Role.name == "Member", Role.organization_id == test_organization.id)).first() + member_role = session.exec( + select(Role).where( + Role.name == "Member", Role.organization_id == test_organization.id + ) + ).first() if not member_role: pytest.fail("Member role not found in test_create_invitation_success") assert member_role.id is not None @@ -275,19 +351,21 @@ def test_create_invitation_success(auth_client, inviter_user: User, test_organiz app.url_path_for("create_invitation"), data={ "invitee_email": invitee_email, - "role_id": str(member_role_id), # Form data is usually string - "organization_id": str(test_organization.id) # Form data is usually string + "role_id": str(member_role_id), # Form data is usually string + "organization_id": str(test_organization.id), # Form data is usually string }, ) - assert response.status_code == 303, f"Expected 303 redirect, got {response.status_code}. Response: {response.text}" # See Other redirect + assert response.status_code == 303, ( + f"Expected 303 redirect, got {response.status_code}. Response: {response.text}" + ) # See Other redirect assert f"/organizations/{test_organization.id}" in response.headers["location"] # Verify invitation was created in DB created = session.exec( select(Invitation).where( Invitation.invitee_email == invitee_email, - Invitation.organization_id == test_organization.id + Invitation.organization_id == test_organization.id, ) ).first() @@ -307,45 +385,69 @@ def test_create_invitation_success(auth_client, inviter_user: User, test_organiz # Extract send params from either kwargs or the first positional argument send_params = call_kwargs or call_args[0] assert send_params["to"] == [invitee_email] - assert test_organization.name in send_params["subject"] # Check org name is in subject + assert ( + test_organization.name in send_params["subject"] + ) # Check org name is in subject # Check token is in email body (assuming HTML content) - assert created.token in send_params.get("html", "") or created.token in send_params.get("text", "") + assert created.token in send_params.get( + "html", "" + ) or created.token in send_params.get("text", "") + -def test_create_invitation_unauthorized(auth_client_member, test_user: User, test_organization: Organization, session: Session): +def test_create_invitation_unauthorized( + auth_client_member, + test_user: User, + test_organization: Organization, + session: Session, +): """Test invitation creation without INVITE_USER permission (using auth_client_member).""" assert test_organization.id is not None # Ensure the Member role exists and get its ID - member_role = session.exec(select(Role).where(Role.name == "Member", Role.organization_id == test_organization.id)).first() + member_role = session.exec( + select(Role).where( + Role.name == "Member", Role.organization_id == test_organization.id + ) + ).first() if not member_role: pytest.fail("Member role not found in test_create_invitation_unauthorized") assert member_role.id is not None - response = auth_client_member.post( # Use client logged in as a regular member + response = auth_client_member.post( # Use client logged in as a regular member app.url_path_for("create_invitation"), data={ "invitee_email": "unauthorized@example.com", "role_id": str(member_role.id), - "organization_id": str(test_organization.id) + "organization_id": str(test_organization.id), }, ) - assert response.status_code == 403, f"Expected 403 Forbidden, got {response.status_code}. Response: {response.text}" # Forbidden + assert response.status_code == 403, ( + f"Expected 403 Forbidden, got {response.status_code}. Response: {response.text}" + ) # Forbidden -def test_create_invitation_for_existing_member(auth_client, inviter_user: User, test_organization: Organization, session: Session): +def test_create_invitation_for_existing_member( + auth_client, inviter_user: User, test_organization: Organization, session: Session +): """Test that inviting an existing member fails.""" assert test_organization.id is not None # Create a user that's already a member existing_email = "existing_member@example.com" - existing_account = session.exec(select(Account).where(Account.email == existing_email)).first() + existing_account = session.exec( + select(Account).where(Account.email == existing_email) + ).first() if not existing_account: - existing_account = Account(email=existing_email, hashed_password="password_hash") + existing_account = Account( + email=existing_email, hashed_password="password_hash" + ) session.add(existing_account) session.commit() session.refresh(existing_account) assert existing_account.id is not None - existing_user = session.exec(select(User).where(User.account_id == existing_account.id)).first() + existing_user = session.exec( + select(User).where(User.account_id == existing_account.id) + ).first() if not existing_user: existing_user = User(name="Existing Member", account_id=existing_account.id) session.add(existing_user) @@ -354,25 +456,26 @@ def test_create_invitation_for_existing_member(auth_client, inviter_user: User, # Find the Member role member_role = session.exec( select(Role).where( - Role.organization_id == test_organization.id, - Role.name == "Member" + Role.organization_id == test_organization.id, Role.name == "Member" ) ).first() if not member_role: - pytest.fail("Member role not found in test_create_invitation_for_existing_member") + pytest.fail( + "Member role not found in test_create_invitation_for_existing_member" + ) assert member_role.id is not None # Add user to organization if not already a member needs_commit = False - if existing_user is None: # Should not happen given above logic, but check anyway - pytest.fail("existing_user is None unexpectedly") - if existing_user.id is None: # User was created but not committed + if existing_user is None: # Should not happen given above logic, but check anyway + pytest.fail("existing_user is None unexpectedly") + if existing_user.id is None: # User was created but not committed needs_commit = True if member_role not in existing_user.roles: existing_user.roles.append(member_role) - session.add(existing_user) # Add again to update roles relationship + session.add(existing_user) # Add again to update roles relationship needs_commit = True if needs_commit: @@ -380,19 +483,24 @@ def test_create_invitation_for_existing_member(auth_client, inviter_user: User, session.refresh(existing_user) # Try to invite the existing member - response = auth_client.post( # Use the client that has permission + response = auth_client.post( # Use the client that has permission app.url_path_for("create_invitation"), data={ "invitee_email": existing_email, "role_id": str(member_role.id), - "organization_id": str(test_organization.id) + "organization_id": str(test_organization.id), }, ) # Expecting a 409 Conflict based on the plan - assert response.status_code == 409, f"Expected 409 Conflict, got {response.status_code}. Response: {response.text}" # Conflict - UserIsAlreadyMemberError + assert response.status_code == 409, ( + f"Expected 409 Conflict, got {response.status_code}. Response: {response.text}" + ) # Conflict - UserIsAlreadyMemberError -def test_create_invitation_duplicate_active(auth_client, inviter_user: User, existing_invitation: Invitation): + +def test_create_invitation_duplicate_active( + auth_client, inviter_user: User, existing_invitation: Invitation +): """Test that creating a duplicate active invitation fails.""" assert existing_invitation.organization_id is not None assert existing_invitation.role_id is not None @@ -401,13 +509,18 @@ def test_create_invitation_duplicate_active(auth_client, inviter_user: User, exi data={ "invitee_email": existing_invitation.invitee_email, # Same email "role_id": str(existing_invitation.role_id), - "organization_id": str(existing_invitation.organization_id) + "organization_id": str(existing_invitation.organization_id), }, ) - assert response.status_code == 409, f"Expected 409 Conflict, got {response.status_code}. Response: {response.text}" # Conflict - ActiveInvitationExistsError + assert response.status_code == 409, ( + f"Expected 409 Conflict, got {response.status_code}. Response: {response.text}" + ) # Conflict - ActiveInvitationExistsError + -def test_create_invitation_role_not_found(auth_client, inviter_user: User, test_organization: Organization): +def test_create_invitation_role_not_found( + auth_client, inviter_user: User, test_organization: Organization +): """Test that specifying a role_id that doesn't exist fails with 404.""" assert test_organization.id is not None non_existent_role_id = 99999 @@ -416,25 +529,30 @@ def test_create_invitation_role_not_found(auth_client, inviter_user: User, test_ data={ "invitee_email": "testrole_notfound@example.com", "role_id": str(non_existent_role_id), - "organization_id": str(test_organization.id) + "organization_id": str(test_organization.id), }, ) # Depending on implementation, this might be 404 (Role Not Found) or 400 (Invalid Role for Org) # The plan suggests 404, let's stick to that. - assert response.status_code == 404, f"Expected 404 Not Found, got {response.status_code}. Response: {response.text}" + assert response.status_code == 404, ( + f"Expected 404 Not Found, got {response.status_code}. Response: {response.text}" + ) -def test_create_invitation_role_wrong_organization(auth_client, inviter_user: User, test_organization: Organization, session: Session): + +def test_create_invitation_role_wrong_organization( + auth_client, inviter_user: User, test_organization: Organization, session: Session +): """Test that specifying a role_id belonging to another org fails with 400.""" assert test_organization.id is not None # Create another organization and a role within it other_org = Organization(name="Other Test Org For Roles") session.add(other_org) - session.commit() # Commit to get ID + session.commit() # Commit to get ID assert other_org.id is not None other_role = Role(name="Other Org Role", organization_id=other_org.id) session.add(other_role) - session.commit() # Commit to get ID + session.commit() # Commit to get ID assert other_role.id is not None response = auth_client.post( @@ -442,23 +560,32 @@ def test_create_invitation_role_wrong_organization(auth_client, inviter_user: Us data={ "invitee_email": "testrole_wrongorg@example.com", "role_id": str(other_role.id), # Role from the wrong org - "organization_id": str(test_organization.id) # Target the main test org + "organization_id": str(test_organization.id), # Target the main test org }, ) # Plan suggests 400 Bad Request - assert response.status_code == 400, f"Expected 400 Bad Request, got {response.status_code}. Response: {response.text}" + assert response.status_code == 400, ( + f"Expected 400 Bad Request, got {response.status_code}. Response: {response.text}" + ) -def test_create_invitation_unauthenticated(unauth_client, test_organization: Organization, session: Session): + +def test_create_invitation_unauthenticated( + unauth_client, test_organization: Organization, session: Session +): """Test invitation attempt without authentication.""" assert test_organization.id is not None # Ensure the Member role exists and get its ID - member_role = session.exec(select(Role).where(Role.name == "Member", Role.organization_id == test_organization.id)).first() + member_role = session.exec( + select(Role).where( + Role.name == "Member", Role.organization_id == test_organization.id + ) + ).first() if not member_role: - member_role = Role(name="Member", organization_id=test_organization.id) - session.add(member_role) - session.commit() - session.refresh(member_role) + member_role = Role(name="Member", organization_id=test_organization.id) + session.add(member_role) + session.commit() + session.refresh(member_role) assert member_role.id is not None response = unauth_client.post( @@ -466,20 +593,35 @@ def test_create_invitation_unauthenticated(unauth_client, test_organization: Org data={ "invitee_email": "unauth@example.com", "role_id": str(member_role.id), - "organization_id": str(test_organization.id) + "organization_id": str(test_organization.id), }, ) - assert response.status_code == 303, f"Expected 303 redirect to login, got {response.status_code}" # Redirect to login + assert response.status_code == 303, ( + f"Expected 303 redirect to login, got {response.status_code}" + ) # Redirect to login assert response.headers["location"] == app.url_path_for("read_login") -def test_create_invitation_email_send_failure(auth_client, inviter_user: User, test_organization: Organization, session: Session, mock_resend_send: MagicMock): + +def test_create_invitation_email_send_failure( + auth_client, + inviter_user: User, + test_organization: Organization, + session: Session, + mock_resend_send: MagicMock, +): """Test that invitation creation fails and rolls back if email sending fails.""" invitee_email = "fail_invite@example.com" assert test_organization.id is not None - member_role = session.exec(select(Role).where(Role.name == "Member", Role.organization_id == test_organization.id)).first() + member_role = session.exec( + select(Role).where( + Role.name == "Member", Role.organization_id == test_organization.id + ) + ).first() if not member_role: - pytest.fail("Member role not found in test_create_invitation_email_send_failure") + pytest.fail( + "Member role not found in test_create_invitation_email_send_failure" + ) assert member_role.id is not None member_role_id = member_role.id @@ -492,25 +634,37 @@ def test_create_invitation_email_send_failure(auth_client, inviter_user: User, t data={ "invitee_email": invitee_email, "role_id": str(member_role_id), - "organization_id": str(test_organization.id) + "organization_id": str(test_organization.id), }, ) - assert response.status_code == 500, f"Expected 500 Internal Server Error, got {response.status_code}. Response: {response.text}" - assert "Failed to send invitation email" in response.text # Check for error detail + assert response.status_code == 500, ( + f"Expected 500 Internal Server Error, got {response.status_code}. Response: {response.text}" + ) + assert "Failed to send invitation email" in response.text # Check for error detail # Verify invitation was NOT created in DB (due to rollback) failed_invitation = session.exec( select(Invitation).where( Invitation.invitee_email == invitee_email, - Invitation.organization_id == test_organization.id + Invitation.organization_id == test_organization.id, ) ).first() - assert failed_invitation is None, "Invitation should not have been created due to email failure and rollback." + assert failed_invitation is None, ( + "Invitation should not have been created due to email failure and rollback." + ) + # --- Organization Page Tests --- -def test_organization_page_shows_active_invitations(auth_client_owner, test_organization: Organization, session: Session, existing_invitation: Invitation, expired_invitation: Invitation, used_invitation: Invitation): +def test_organization_page_shows_active_invitations( + auth_client_owner, + test_organization: Organization, + session: Session, + existing_invitation: Invitation, + expired_invitation: Invitation, + used_invitation: Invitation, +): """Test that the organization page shows active invitations.""" assert test_organization.id is not None response = auth_client_owner.get( @@ -521,13 +675,25 @@ def test_organization_page_shows_active_invitations(auth_client_owner, test_orga response_text = response.text # Active invitation email should be in response (Depends on Invitation model) - assert existing_invitation.invitee_email in response_text # Ignored Error: Invitation model not defined yet + assert ( + existing_invitation.invitee_email in response_text + ) # Ignored Error: Invitation model not defined yet # Expired and used invitation emails should not be in response (Depends on Invitation model) - assert expired_invitation.invitee_email not in response_text # Ignored Error: Invitation model not defined yet - assert used_invitation.invitee_email not in response_text # Ignored Error: Invitation model not defined yet - -def test_organization_page_invite_form_visibility(auth_client_owner, auth_client_admin, auth_client_member, test_organization: Organization): + assert ( + expired_invitation.invitee_email not in response_text + ) # Ignored Error: Invitation model not defined yet + assert ( + used_invitation.invitee_email not in response_text + ) # Ignored Error: Invitation model not defined yet + + +def test_organization_page_invite_form_visibility( + auth_client_owner, + auth_client_admin, + auth_client_member, + test_organization: Organization, +): """Test that the invitation form is only shown to users with INVITE_USER permission.""" assert test_organization.id is not None # Owner should see invitation form (has INVITE_USER permission via Owner role -> permission fixture) @@ -535,18 +701,24 @@ def test_organization_page_invite_form_visibility(auth_client_owner, auth_client app.url_path_for("read_organization", org_id=test_organization.id), ) assert owner_response.status_code == 200 - assert ' permission fixture) admin_response = auth_client_admin.get( app.url_path_for("read_organization", org_id=test_organization.id), ) assert admin_response.status_code == 200 - assert ']*data-bs-target="#editRoleModal{custom_role.id}"[^>]*>\s*Edit Role\s*' + edit_button_pattern = rf']*data-bs-target="#editRoleModal{custom_role.id}"[^>]*>\s*Edit Role\s*' # Owner should see the edit button for the custom role owner_response = auth_client_owner.get( app.url_path_for("read_organization", org_id=test_organization.id) ) assert owner_response.status_code == 200 - assert re.search(edit_button_pattern, owner_response.text) is not None, "Owner should see edit button for custom role" + assert re.search(edit_button_pattern, owner_response.text) is not None, ( + "Owner should see edit button for custom role" + ) # Admin should see the edit button for the custom role admin_response = auth_client_admin.get( app.url_path_for("read_organization", org_id=test_organization.id) ) assert admin_response.status_code == 200 - assert re.search(edit_button_pattern, admin_response.text) is not None, "Admin should see edit button for custom role" + assert re.search(edit_button_pattern, admin_response.text) is not None, ( + "Admin should see edit button for custom role" + ) # Member should not see *any* edit role button member_response = auth_client_member.get( @@ -524,13 +525,24 @@ def test_organization_page_role_edit_access(auth_client_owner, auth_client_admin ) assert member_response.status_code == 200 # Use a general pattern to ensure no edit buttons are present for the member - assert re.search(r']*>\\s*Edit Role\\s*', member_response.text) is None, "Member should not see any edit buttons" - - -def test_organization_page_role_delete_access(auth_client_owner, auth_client_admin, auth_client_member, test_organization, session: Session): + assert ( + re.search(r"]*>\\s*Edit Role\\s*", member_response.text) + is None + ), "Member should not see any edit buttons" + + +def test_organization_page_role_delete_access( + auth_client_owner, + auth_client_admin, + auth_client_member, + test_organization, + session: Session, +): """Test that role deletion UI elements are only shown to users with DELETE_ROLE permission""" # Create a custom, deletable role for the test - custom_role = Role(name="Custom Role To Delete", organization_id=test_organization.id) + custom_role = Role( + name="Custom Role To Delete", organization_id=test_organization.id + ) session.add(custom_role) session.commit() session.refresh(custom_role) @@ -547,8 +559,8 @@ def test_organization_page_role_delete_access(auth_client_owner, auth_client_adm rf']*action="http://testserver{re.escape(str(app.url_path_for("delete_role")))}"[^>]*>' rf'.*?' rf'.*?' - rf'.*?Delete Role' - rf'.*?' + rf".*?Delete Role" + rf".*?" ) assert re.search(delete_form_pattern, owner_response.text, re.DOTALL) is not None @@ -572,14 +584,22 @@ def test_organization_page_role_delete_access(auth_client_owner, auth_client_adm built_in_delete_pattern = ( rf']*action="http://testserver{re.escape(str(app.url_path_for("delete_role")))}"[^>]*>' rf'(?:(?!)[\s\S])*?' - rf'(?:(?!)[\s\S])*?' + rf"(?:(?!)[\s\S])*?" + ) + assert ( + re.search(built_in_delete_pattern, owner_response.text, re.DOTALL) is None + ) + assert ( + re.search(built_in_delete_pattern, admin_response.text, re.DOTALL) is None + ) + assert ( + re.search(built_in_delete_pattern, member_response.text, re.DOTALL) is None ) - assert re.search(built_in_delete_pattern, owner_response.text, re.DOTALL) is None - assert re.search(built_in_delete_pattern, admin_response.text, re.DOTALL) is None - assert re.search(built_in_delete_pattern, member_response.text, re.DOTALL) is None -def test_organization_page_always_shows_default_roles(auth_client_member, test_organization, session: Session): +def test_organization_page_always_shows_default_roles( + auth_client_member, test_organization, session: Session +): """ Test that Owner, Administrator, and Member roles are always visible on the organization page, even if no custom roles exist. @@ -589,7 +609,7 @@ def test_organization_page_always_shows_default_roles(auth_client_member, test_o custom_roles = session.exec( select(Role).where( Role.organization_id == test_organization.id, - col(Role.name).not_in(["Owner", "Administrator", "Member"]) + col(Role.name).not_in(["Owner", "Administrator", "Member"]), ) ).all() assert len(custom_roles) == 0, "Test setup failed: Custom roles exist unexpectedly." @@ -606,7 +626,9 @@ def test_organization_page_always_shows_default_roles(auth_client_member, test_o assert "Member" in response.text -def test_organization_page_no_edit_for_default_roles(auth_client_owner, test_organization): +def test_organization_page_no_edit_for_default_roles( + auth_client_owner, test_organization +): """ Test that the 'Edit Role' button/modal trigger does not appear for default roles (Owner, Administrator, Member), even for the owner. @@ -617,24 +639,42 @@ def test_organization_page_no_edit_for_default_roles(auth_client_owner, test_org assert response.status_code == 200 # Owner role (ID 1) should not have an edit button - owner_edit_button_pattern = r']*data-bs-target="#editRoleModal1"[^>]*>\\s*Edit Role\\s*' - assert re.search(owner_edit_button_pattern, response.text) is None, "Edit button found for Owner role" + owner_edit_button_pattern = ( + r']*data-bs-target="#editRoleModal1"[^>]*>\\s*Edit Role\\s*' + ) + assert re.search(owner_edit_button_pattern, response.text) is None, ( + "Edit button found for Owner role" + ) # Administrator role (ID 2) should not have an edit button - admin_edit_button_pattern = r']*data-bs-target="#editRoleModal2"[^>]*>\\s*Edit Role\\s*' - assert re.search(admin_edit_button_pattern, response.text) is None, "Edit button found for Administrator role" + admin_edit_button_pattern = ( + r']*data-bs-target="#editRoleModal2"[^>]*>\\s*Edit Role\\s*' + ) + assert re.search(admin_edit_button_pattern, response.text) is None, ( + "Edit button found for Administrator role" + ) # Member role (ID 3) should not have an edit button - member_edit_button_pattern = r']*data-bs-target="#editRoleModal3"[^>]*>\\s*Edit Role\\s*' - assert re.search(member_edit_button_pattern, response.text) is None, "Edit button found for Member role" + member_edit_button_pattern = ( + r']*data-bs-target="#editRoleModal3"[^>]*>\\s*Edit Role\\s*' + ) + assert re.search(member_edit_button_pattern, response.text) is None, ( + "Edit button found for Member role" + ) # Check that the edit modals themselves are not generated for default roles assert 'id="editRoleModal1"' not in response.text, "Edit modal found for Owner role" - assert 'id="editRoleModal2"' not in response.text, "Edit modal found for Administrator role" - assert 'id="editRoleModal3"' not in response.text, "Edit modal found for Member role" + assert 'id="editRoleModal2"' not in response.text, ( + "Edit modal found for Administrator role" + ) + assert 'id="editRoleModal3"' not in response.text, ( + "Edit modal found for Member role" + ) -def test_organization_page_no_delete_for_default_roles(auth_client_owner, test_organization): +def test_organization_page_no_delete_for_default_roles( + auth_client_owner, test_organization +): """ Test that the 'Delete Role' button/form does not appear for default roles (Owner, Administrator, Member), even for the owner. @@ -647,19 +687,28 @@ def test_organization_page_no_delete_for_default_roles(auth_client_owner, test_o # Check that delete forms are NOT present for default roles (IDs 1, 2, 3) owner_delete_form_pattern = r']*action="[^"]*?/roles/delete"[^>]*>.*?]*name="id"[^>]*value="1"[^>]*>.*?' - assert re.search(owner_delete_form_pattern, response.text, re.DOTALL) is None, "Delete form found for Owner role" + assert re.search(owner_delete_form_pattern, response.text, re.DOTALL) is None, ( + "Delete form found for Owner role" + ) admin_delete_form_pattern = r']*action="[^"]*?/roles/delete"[^>]*>.*?]*name="id"[^>]*value="2"[^>]*>.*?' - assert re.search(admin_delete_form_pattern, response.text, re.DOTALL) is None, "Delete form found for Administrator role" + assert re.search(admin_delete_form_pattern, response.text, re.DOTALL) is None, ( + "Delete form found for Administrator role" + ) member_delete_form_pattern = r']*action="[^"]*?/roles/delete"[^>]*>.*?]*name="id"[^>]*value="3"[^>]*>.*?' - assert re.search(member_delete_form_pattern, response.text, re.DOTALL) is None, "Delete form found for Member role" + assert re.search(member_delete_form_pattern, response.text, re.DOTALL) is None, ( + "Delete form found for Member role" + ) # --- API Endpoint Tests for Default Roles --- + @pytest.mark.parametrize("default_role_name", ["Owner", "Administrator", "Member"]) -def test_update_default_role_api_forbidden(auth_client_owner, test_organization, session: Session, default_role_name): +def test_update_default_role_api_forbidden( + auth_client_owner, test_organization, session: Session, default_role_name +): """ Test that attempting to update default roles via the API is forbidden (403). Uses the owner client which has EDIT_ROLE permission. @@ -673,25 +722,29 @@ def test_update_default_role_api_forbidden(auth_client_owner, test_organization, ).first() if not default_role: - pytest.fail(f"Default role '{default_role_name}' not found for organization {test_organization.id}") - + pytest.fail( + f"Default role '{default_role_name}' not found for organization {test_organization.id}" + ) + default_role_id = default_role.id response = auth_client_owner.post( app.url_path_for("update_role"), data={ - "id": default_role_id, # Use dynamically fetched ID + "id": default_role_id, # Use dynamically fetched ID "name": f"Attempt to Change {default_role_name} Role", "organization_id": test_organization.id, - "permissions": [ValidPermissions.EDIT_ROLE.value] # Arbitrary permission - } + "permissions": [ValidPermissions.EDIT_ROLE.value], # Arbitrary permission + }, ) - assert response.status_code == 403 # Expecting Forbidden + assert response.status_code == 403 # Expecting Forbidden @pytest.mark.parametrize("default_role_name", ["Owner", "Administrator", "Member"]) -def test_delete_default_role_api_forbidden(auth_client_owner, test_organization, session: Session, default_role_name): +def test_delete_default_role_api_forbidden( + auth_client_owner, test_organization, session: Session, default_role_name +): """ Test that attempting to delete default roles via the API is forbidden (403). Uses the owner client which has DELETE_ROLE permission. @@ -705,7 +758,9 @@ def test_delete_default_role_api_forbidden(auth_client_owner, test_organization, ).first() if not default_role: - pytest.fail(f"Default role '{default_role_name}' not found for organization {test_organization.id}") + pytest.fail( + f"Default role '{default_role_name}' not found for organization {test_organization.id}" + ) default_role_id = default_role.id print(default_role_id) @@ -713,12 +768,12 @@ def test_delete_default_role_api_forbidden(auth_client_owner, test_organization, response = auth_client_owner.post( app.url_path_for("delete_role"), data={ - "id": default_role_id, # Use dynamically fetched ID - "organization_id": test_organization.id - } + "id": default_role_id, # Use dynamically fetched ID + "organization_id": test_organization.id, + }, ) - assert response.status_code == 403 # Expecting Forbidden + assert response.status_code == 403 # Expecting Forbidden def test_create_role_form_modal(auth_client_owner, test_organization): @@ -731,12 +786,14 @@ def test_create_role_form_modal(auth_client_owner, test_organization): # Check for modal elements assert 'id="createRoleModal"' in response.text - assert f'action="http://testserver{app.url_path_for('create_role')}"' in response.text + assert ( + f'action="http://testserver{app.url_path_for("create_role")}"' in response.text + ) assert 'method="POST"' in response.text or 'method="post"' in response.text assert 'name="name"' in response.text assert 'name="organization_id"' in response.text assert f'value="{test_organization.id}"' in response.text - + # Check for permission checkboxes for permission in list(ValidPermissions) + list(AppPermissions): assert permission.value in response.text @@ -745,10 +802,7 @@ def test_create_role_form_modal(auth_client_owner, test_organization): def test_edit_role_form_modal(auth_client_owner, session, test_organization): """Test that the edit role modal form contains all required elements and pre-fills data""" # Create a test role to edit - test_role = Role( - name="Test Edit Role", - organization_id=test_organization.id - ) + test_role = Role(name="Test Edit Role", organization_id=test_organization.id) # Add some permissions edit_permission = session.exec( @@ -767,7 +821,9 @@ def test_edit_role_form_modal(auth_client_owner, session, test_organization): # Verify DELETE_ROLE permission is NOT in the role's permissions before making request role_permission_names = [p.name for p in test_role.permissions] - assert ValidPermissions.DELETE_ROLE not in role_permission_names, "DELETE_ROLE should not be in permissions before test" + assert ValidPermissions.DELETE_ROLE not in role_permission_names, ( + "DELETE_ROLE should not be in permissions before test" + ) response = auth_client_owner.get( app.url_path_for("read_organization", org_id=test_organization.id) @@ -776,7 +832,9 @@ def test_edit_role_form_modal(auth_client_owner, session, test_organization): # Check for modal elements assert f'id="editRoleModal{test_role.id}"' in response.text - assert f'action="http://testserver{app.url_path_for('update_role')}"' in response.text + assert ( + f'action="http://testserver{app.url_path_for("update_role")}"' in response.text + ) assert 'method="POST"' in response.text or 'method="post"' in response.text assert 'name="name"' in response.text assert f'value="{test_role.name}"' in response.text @@ -791,28 +849,31 @@ def test_edit_role_form_modal(auth_client_owner, session, test_organization): # These should be checked - use regex for robustness edit_role_pattern = f']*\\svalue="{re.escape(ValidPermissions.EDIT_ROLE.value)}")(?=[^>]*\\sid="perm_{test_role.id}_{re.escape(ValidPermissions.EDIT_ROLE.value.replace(" ", "_"))}")[^>]*\\s+checked[^>]*>' - assert re.search(edit_role_pattern, response.text) is not None, f"Checkbox for {ValidPermissions.EDIT_ROLE.value} should be checked" + assert re.search(edit_role_pattern, response.text) is not None, ( + f"Checkbox for {ValidPermissions.EDIT_ROLE.value} should be checked" + ) invite_user_pattern = f']*\\svalue="{re.escape(ValidPermissions.INVITE_USER.value)}")(?=[^>]*\\sid="perm_{test_role.id}_{re.escape(ValidPermissions.INVITE_USER.value.replace(" ", "_"))}")[^>]*\\s+checked[^>]*>' - assert re.search(invite_user_pattern, response.text) is not None, f"Checkbox for {ValidPermissions.INVITE_USER.value} should be checked" + assert re.search(invite_user_pattern, response.text) is not None, ( + f"Checkbox for {ValidPermissions.INVITE_USER.value} should be checked" + ) # Check for one that should NOT be checked delete_role_pattern = f']*\\svalue="{re.escape(ValidPermissions.DELETE_ROLE.value)}")(?=[^>]*\\sid="perm_{test_role.id}_{re.escape(ValidPermissions.DELETE_ROLE.value.replace(" ", "_"))}")[^>]*\\s+checked[^>]*>' delete_match = re.search(delete_role_pattern, response.text) - assert delete_match is None, f"Checkbox for {ValidPermissions.DELETE_ROLE.value} should NOT be checked" + assert delete_match is None, ( + f"Checkbox for {ValidPermissions.DELETE_ROLE.value} should NOT be checked" + ) def test_delete_role_form(auth_client_owner, session, test_organization): """Test that the delete role form contains all required elements""" # Create a test role to delete - test_role = Role( - name="Test Delete Role", - organization_id=test_organization.id - ) + test_role = Role(name="Test Delete Role", organization_id=test_organization.id) session.add(test_role) session.commit() session.refresh(test_role) - + response = auth_client_owner.get( app.url_path_for("read_organization", org_id=test_organization.id) ) @@ -820,7 +881,9 @@ def test_delete_role_form(auth_client_owner, session, test_organization): assert response.status_code == 200 # Check for delete form elements - assert f'action="http://testserver{app.url_path_for('delete_role')}"' in response.text + assert ( + f'action="http://testserver{app.url_path_for("delete_role")}"' in response.text + ) assert 'method="POST"' in response.text or 'method="post"' in response.text assert 'name="id"' in response.text assert f'value="{test_role.id}"' in response.text diff --git a/tests/routers/core/test_static_pages.py b/tests/routers/core/test_static_pages.py index 057431b..03d737b 100644 --- a/tests/routers/core/test_static_pages.py +++ b/tests/routers/core/test_static_pages.py @@ -7,20 +7,14 @@ @pytest.mark.parametrize("page_name", valid_page_names) -def test_read_static_page_unauthenticated( - unauth_client: TestClient, - page_name: str -): +def test_read_static_page_unauthenticated(unauth_client: TestClient, page_name: str): """Test that valid static pages return 200 OK for unauthenticated users.""" response = unauth_client.get(f"/{page_name}") assert response.status_code == 200 @pytest.mark.parametrize("page_name", valid_page_names) -def test_read_static_page_authenticated( - auth_client: TestClient, - page_name: str -): +def test_read_static_page_authenticated(auth_client: TestClient, page_name: str): """Test that valid static pages return 200 OK for authenticated users.""" response = auth_client.get(f"/{page_name}") assert response.status_code == 200 diff --git a/tests/routers/core/test_user.py b/tests/routers/core/test_user.py index c8e72b5..b71e357 100644 --- a/tests/routers/core/test_user.py +++ b/tests/routers/core/test_user.py @@ -15,8 +15,7 @@ def test_read_profile_unauthorized(unauth_client: TestClient): """Test that unauthorized users cannot view profile""" - response = unauth_client.get(app.url_path_for( - "read_profile")) + response = unauth_client.get(app.url_path_for("read_profile")) assert response.status_code == 303 # Redirect to login assert response.headers["location"] == app.url_path_for("read_login") @@ -25,17 +24,17 @@ def test_read_profile_authorized(auth_client: TestClient, test_user: User): """Test that authorized users can view their profile""" response = auth_client.get(app.url_path_for("read_profile")) assert response.status_code == 200 - + # Get the response text response_text = response.text - + # Verify account exists assert test_user.account is not None - + # Verify email is in the response if it exists if test_user.account.email is not None: assert response_text.find(str(test_user.account.email)) != -1 - + # Verify name is in the response if it exists if test_user.name is not None: assert response_text.find(str(test_user.name)) != -1 @@ -45,35 +44,27 @@ def test_update_profile_unauthorized(unauth_client: TestClient): """Test that unauthorized users cannot edit profile""" response: Response = unauth_client.post( app.url_path_for("update_profile"), - data={ - "name": "New Name" - }, - files={ - "avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg") - }, + data={"name": "New Name"}, + files={"avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg")}, ) assert response.status_code == 303 # Redirect to login assert response.headers["location"] == app.url_path_for("read_login") -@patch('routers.core.user.validate_and_process_image') +@patch("routers.core.user.validate_and_process_image") def test_update_profile_authorized( - mock_validate: MagicMock, auth_client: TestClient, test_user: User, session: Session - ): + mock_validate: MagicMock, auth_client: TestClient, test_user: User, session: Session +): """Test that authorized users can edit their profile""" - + # Configure mock to return processed image data mock_validate.return_value = (MOCK_IMAGE_DATA, MOCK_CONTENT_TYPE) - + # Update profile response: Response = auth_client.post( app.url_path_for("update_profile"), - data={ - "name": "Updated Name" - }, - files={ - "avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg") - }, + data={"name": "Updated Name"}, + files={"avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg")}, ) assert response.status_code == 303 assert response.headers["location"] == app.url_path_for("read_profile") @@ -89,13 +80,13 @@ def test_update_profile_authorized( mock_validate.assert_called_once() -def test_update_profile_without_avatar(auth_client: TestClient, test_user: User, session: Session): +def test_update_profile_without_avatar( + auth_client: TestClient, test_user: User, session: Session +): """Test that profile can be updated without changing the avatar""" response: Response = auth_client.post( app.url_path_for("update_profile"), - data={ - "name": "Updated Name" - }, + data={"name": "Updated Name"}, ) assert response.status_code == 303 assert response.headers["location"] == app.url_path_for("read_profile") @@ -121,14 +112,16 @@ def test_delete_account_wrong_password(auth_client: TestClient, test_user: User) app.url_path_for("delete_account"), data={ "email": test_user.account.email if test_user.account else "", - "password": "WrongPassword123!" + "password": "WrongPassword123!", }, ) assert response.status_code == 422 assert "Password is incorrect" in response.text.strip() -def test_delete_account_success(auth_client: TestClient, test_user: User, session: Session): +def test_delete_account_success( + auth_client: TestClient, test_user: User, session: Session +): """Test successful account deletion""" # Store the user ID for later verification @@ -139,7 +132,7 @@ def test_delete_account_success(auth_client: TestClient, test_user: User, sessio app.url_path_for("delete_account"), data={ "email": test_user.account.email if test_user.account else "", - "password": "Test123!@#" + "password": "Test123!@#", }, ) assert response.status_code == 303 @@ -148,16 +141,16 @@ def test_delete_account_success(auth_client: TestClient, test_user: User, sessio # Clear the session and query for the user again to ensure we're not using a cached object session.close() session.expire_all() - + # Verify user is deleted from database user = session.get(User, user_id) assert user is None -@patch('routers.core.user.validate_and_process_image') +@patch("routers.core.user.validate_and_process_image") def test_get_avatar_authorized( - mock_validate: MagicMock, auth_client: TestClient, test_user: User - ): + mock_validate: MagicMock, auth_client: TestClient, test_user: User +): """Test getting user avatar""" # Configure mock to return processed image data mock_validate.return_value = (MOCK_IMAGE_DATA, MOCK_CONTENT_TYPE) @@ -168,15 +161,11 @@ def test_get_avatar_authorized( data={ "name": test_user.name or "" # Ensure name is not None }, - files={ - "avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg") - }, + files={"avatar_file": ("test_avatar.jpg", b"fake image data", "image/jpeg")}, ) # Then try to retrieve it - response = auth_client.get( - app.url_path_for("get_avatar") - ) + response = auth_client.get(app.url_path_for("get_avatar")) assert response.status_code == 200 assert response.content == MOCK_IMAGE_DATA assert response.headers["content-type"] == MOCK_CONTENT_TYPE @@ -192,22 +181,18 @@ def test_get_avatar_unauthorized(unauth_client: TestClient): # Add new test for invalid image -@patch('routers.core.user.validate_and_process_image') +@patch("routers.core.user.validate_and_process_image") def test_update_profile_invalid_image( - mock_validate: MagicMock, auth_client: TestClient - ): + mock_validate: MagicMock, auth_client: TestClient +): """Test that invalid images are rejected""" # Configure mock to raise InvalidImageError mock_validate.side_effect = InvalidImageError("Invalid test image") - + response: Response = auth_client.post( app.url_path_for("update_profile"), - data={ - "name": "Updated Name" - }, - files={ - "avatar_file": ("test_avatar.jpg", b"invalid image data", "image/jpeg") - }, + data={"name": "Updated Name"}, + files={"avatar_file": ("test_avatar.jpg", b"invalid image data", "image/jpeg")}, ) assert response.status_code == 400 assert "Invalid test image" in response.text @@ -215,20 +200,22 @@ def test_update_profile_invalid_image( # --- Multi-Organization Profile Tests --- + def test_profile_displays_multiple_organizations( - auth_client: TestClient, test_user: User, session: Session, test_organization: Organization, second_test_organization: Organization - ): + auth_client: TestClient, + test_user: User, + session: Session, + test_organization: Organization, + second_test_organization: Organization, +): """Test that a user's profile page displays all organizations they belong to""" if second_test_organization.id is None: raise SetupError("Second test organization ID is None") - + # Ensure test_user is part of both organizations # First org should already be set up through the org_owner fixture # Now add to second org - member_role = Role( - name="Member", - organization_id=second_test_organization.id - ) + member_role = Role(name="Member", organization_id=second_test_organization.id) test_user.roles.append(member_role) session.add(member_role) session.commit() @@ -243,44 +230,51 @@ def test_profile_displays_multiple_organizations( def test_profile_displays_organization_list( - auth_client_owner: TestClient, session: Session, test_organization: Organization - ): + auth_client_owner: TestClient, session: Session, test_organization: Organization +): """Test that the profile page shows organizations in a macro-rendered list""" - + response = auth_client_owner.get(app.url_path_for("read_profile")) assert response.status_code == 200 - + # Find the entire Organizations card section using regex # This regex looks for the card div, the header with "Organizations" and the button, # and captures everything until the next card's div or the end of the container org_section_match = re.search( r'(
\s*
\s*Organizations\s*.*?
.*?
\s*
)', - response.text, - re.DOTALL # Allow . to match newline characters + response.text, + re.DOTALL, # Allow . to match newline characters ) - + # Check that the organizations section was found assert org_section_match, "Organizations card section not found in profile HTML" - + # Extract the matched HTML for the organizations section org_section_html = org_section_match.group(1) - + # Check that the organization name and link are rendered within this specific section - assert test_organization.name in org_section_html, f"Organization name '{test_organization.name}' not found within the organizations card section" - assert app.url_path_for("read_organization", org_id=test_organization.id) in org_section_html, f"Organization link '{app.url_path_for('read_organization', org_id=test_organization.id)}' not found within the organizations card section" + assert test_organization.name in org_section_html, ( + f"Organization name '{test_organization.name}' not found within the organizations card section" + ) + assert ( + app.url_path_for("read_organization", org_id=test_organization.id) + in org_section_html + ), ( + f"Organization link '{app.url_path_for('read_organization', org_id=test_organization.id)}' not found within the organizations card section" + ) def test_profile_no_organizations( - auth_client: TestClient, test_user: User, session: Session - ): + auth_client: TestClient, test_user: User, session: Session +): """Test profile display when user has no organizations""" # Remove user from all orgs by clearing roles test_user.roles = [] session.commit() - + # Visit profile page response = auth_client.get(app.url_path_for("read_profile")) assert response.status_code == 200 - + # Should show "no organizations" message assert "You are not a member of any organizations" in response.text diff --git a/tests/test_bootstrap_htmx.py b/tests/test_bootstrap_htmx.py index 652384e..bcc0435 100644 --- a/tests/test_bootstrap_htmx.py +++ b/tests/test_bootstrap_htmx.py @@ -25,20 +25,16 @@ def test_scripts_in_head_not_body(unauth_client): head, body = html.split("", 1) # Bootstrap and htmx must be in with defer - assert "bootstrap.bundle.min.js" in head, ( - "Bootstrap script must be in " - ) + assert "bootstrap.bundle.min.js" in head, "Bootstrap script must be in " assert "htmx" in head, "htmx script must be in " - assert 'defer' in head, "Scripts in must use defer" + assert "defer" in head, "Scripts in must use defer" # They must NOT be in assert "bootstrap.bundle.min.js" not in body, ( "Bootstrap script must not be in — hx-boost will " "re-process it during swaps, destroying event delegation" ) - assert "htmx.min.js" not in body, ( - "htmx script must not be in " - ) + assert "htmx.min.js" not in body, "htmx script must not be in " # App JS must also be in with defer assert "app.js" in head, "app.js must be in " diff --git a/tests/test_htmx.py b/tests/test_htmx.py index fc3eaa6..727fb8a 100644 --- a/tests/test_htmx.py +++ b/tests/test_htmx.py @@ -7,6 +7,7 @@ - Navigation responses return 200 with HX-Redirect header. - Non-HTMX paths remain unchanged (303 RedirectResponse or full-page error). """ + import pytest from starlette.requests import Request from fastapi.templating import Jinja2Templates @@ -39,6 +40,7 @@ def _reset_rate_limiters(): # 1.3 — is_htmx_request helper # --------------------------------------------------------------------------- + def test_is_htmx_request_true(): scope = { "type": "http", @@ -67,6 +69,7 @@ def test_is_htmx_request_false(): # 1.4 — Exception handler branches # --------------------------------------------------------------------------- + def _assert_htmx_error_is_oob_only(response): """Assert an HTMX error response contains only OOB-swapped content. @@ -153,9 +156,11 @@ def test_validation_error_returns_full_page_for_non_htmx(unauth_client): # 1.5 — Non-HTMX error pages: human-readable, consistent navigation # --------------------------------------------------------------------------- + def test_password_validation_error_non_htmx_shows_readable_message(unauth_client): """PasswordValidationError must render human-readable text, not raw dicts.""" from html import unescape + response = unauth_client.post( "/account/register", data={ @@ -199,30 +204,32 @@ def test_non_htmx_error_pages_have_go_back_and_home_links(unauth_client): # 1.6 — Auth forms include hx-post for HTMX submission # --------------------------------------------------------------------------- + def test_login_form_has_hx_post(unauth_client): """Login form must include hx-post so submissions go through HTMX.""" response = unauth_client.get("/account/login") assert response.status_code == 200 - assert 'hx-post' in response.text + assert "hx-post" in response.text def test_register_form_has_hx_post(unauth_client): """Register form must include hx-post so submissions go through HTMX.""" response = unauth_client.get("/account/register") assert response.status_code == 200 - assert 'hx-post' in response.text + assert "hx-post" in response.text def test_forgot_password_form_has_hx_post(unauth_client): """Forgot password form must include hx-post so submissions go through HTMX.""" response = unauth_client.get("/account/forgot_password") assert response.status_code == 200 - assert 'hx-post' in response.text + assert "hx-post" in response.text def test_reset_password_form_has_hx_post(unauth_client, session, test_account): """Reset password form must include hx-post so submissions go through HTMX.""" from utils.core.models import PasswordResetToken + token = PasswordResetToken(account_id=test_account.id) session.add(token) session.commit() @@ -231,13 +238,14 @@ def test_reset_password_form_has_hx_post(unauth_client, session, test_account): params={"email": test_account.email, "token": token.token}, ) assert response.status_code == 200 - assert 'hx-post' in response.text + assert "hx-post" in response.text # --------------------------------------------------------------------------- # 1.7 — Auth form HTMX success returns HX-Redirect (not 303) # --------------------------------------------------------------------------- + def test_login_htmx_success_returns_hx_redirect(unauth_client, test_account): """HTMX login success must return HX-Redirect header, not a 303.""" response = unauth_client.post( @@ -286,6 +294,7 @@ def test_reset_password_htmx_success_returns_hx_redirect( ): """HTMX reset-password success must return HX-Redirect, not a 303.""" from utils.core.models import PasswordResetToken + token = PasswordResetToken(account_id=test_account.id) session.add(token) session.commit() @@ -308,6 +317,7 @@ def test_reset_password_htmx_success_returns_hx_redirect( # 4.2 — Password mismatch on register/reset # --------------------------------------------------------------------------- + def test_password_mismatch_htmx_returns_toast(unauth_client): response = unauth_client.post( "/account/register", @@ -329,6 +339,7 @@ def test_password_mismatch_htmx_returns_toast(unauth_client): # 4.3 — Login failure toast # --------------------------------------------------------------------------- + def test_bad_login_htmx_returns_toast(unauth_client): response = unauth_client.post( "/account/login", @@ -345,6 +356,7 @@ def test_bad_login_htmx_returns_toast(unauth_client): # 2.3 — Role CRUD endpoints # --------------------------------------------------------------------------- + def test_create_role_htmx_returns_partial(auth_client_owner, test_organization): assert test_organization.id is not None response = auth_client_owner.post( @@ -374,9 +386,12 @@ def test_create_role_non_htmx_redirects(auth_client_owner, test_organization): assert response.headers["location"] == f"/organizations/{test_organization.id}" -def test_delete_role_htmx_returns_partial(auth_client_owner, test_organization, session): +def test_delete_role_htmx_returns_partial( + auth_client_owner, test_organization, session +): """After deleting a custom role with HTMX, returns updated roles table partial.""" from utils.core.models import Role + # Create a custom role to delete custom_role = Role(name="ToDelete", organization_id=test_organization.id) session.add(custom_role) @@ -397,7 +412,9 @@ def test_delete_role_htmx_returns_partial(auth_client_owner, test_organization, assert "ToDelete" not in response.text -def test_create_role_htmx_returns_modal_markup_for_new_role(auth_client_owner, test_organization): +def test_create_role_htmx_returns_modal_markup_for_new_role( + auth_client_owner, test_organization +): assert test_organization.id is not None response = auth_client_owner.post( "/roles/create", @@ -417,6 +434,7 @@ def test_create_role_htmx_returns_modal_markup_for_new_role(auth_client_owner, t # 2.4 — Invitation endpoint # --------------------------------------------------------------------------- + def test_create_invitation_htmx_returns_invitations_partial( auth_client_owner, test_organization, member_role, mock_resend_send ): @@ -440,6 +458,7 @@ def test_create_invitation_htmx_returns_invitations_partial( # 3.2 — Update profile endpoint # --------------------------------------------------------------------------- + def test_update_profile_htmx_returns_profile_display(auth_client): response = auth_client.post( "/user/update", @@ -469,9 +488,13 @@ def test_avatar_url_includes_cache_buster(): query param so the browser doesn't show a stale image after upload.""" import pathlib import re + template = ( pathlib.Path(__file__).resolve().parent.parent - / "templates" / "users" / "partials" / "profile_display.html" + / "templates" + / "users" + / "partials" + / "profile_display.html" ).read_text() assert "get_avatar" in template, "Template should reference get_avatar" # The src should NOT end right after url_for — it must have a query param @@ -485,6 +508,7 @@ def test_avatar_upload_htmx_returns_oob_swap(auth_client): swap for the navbar avatar instead of a full page refresh.""" import io from PIL import Image + buf = io.BytesIO() Image.new("RGB", (100, 100), color="red").save(buf, format="PNG") buf.seek(0) @@ -525,6 +549,7 @@ def test_update_profile_non_htmx_redirects(auth_client): # 4.1 — Business logic errors via HTTPException handler # --------------------------------------------------------------------------- + def test_duplicate_org_name_htmx_returns_toast(auth_client, test_organization): assert test_organization.id is not None response = auth_client.post( @@ -582,7 +607,10 @@ def test_remove_last_non_owner_member_htmx_preserves_empty_state( # 2.3 — update_role HTMX refreshes both table and modal container # --------------------------------------------------------------------------- -def test_update_role_htmx_refreshes_modal_container(auth_client_owner, test_organization, session): + +def test_update_role_htmx_refreshes_modal_container( + auth_client_owner, test_organization, session +): """ update_role HTMX response includes the updated role name in the table and refreshes the role-modals-container OOB so the edit modal title @@ -622,6 +650,7 @@ def test_update_role_htmx_refreshes_modal_container(auth_client_owner, test_orga # 5.1 — Rate limit 429 toast responses # --------------------------------------------------------------------------- + def test_login_rate_limit_htmx_returns_toast(unauth_client): """Rate-limited HTMX login returns a 429 toast partial with Retry-After.""" for _ in range(login_ip_limiter.max_attempts): @@ -667,6 +696,7 @@ def test_forgot_password_rate_limit_htmx_returns_toast(unauth_client): # 6.1 — toast_response helper # --------------------------------------------------------------------------- + def test_toast_response_helper(): """toast_response returns a TemplateResponse with toast HTML.""" templates = Jinja2Templates(directory="templates") @@ -697,8 +727,12 @@ def test_toast_response_with_headers(): } request = Request(scope) resp = toast_response( - request, templates, "Rate limited", level="danger", - status_code=429, headers={"Retry-After": "60"}, + request, + templates, + "Rate limited", + level="danger", + status_code=429, + headers={"Retry-After": "60"}, ) assert resp.headers["Retry-After"] == "60" @@ -729,6 +763,7 @@ def test_append_toast_helper(): # 6.2 — Success toasts in HTMX mutation responses # --------------------------------------------------------------------------- + def test_update_profile_htmx_includes_success_toast(auth_client): response = auth_client.post( "/user/update", @@ -782,8 +817,11 @@ def test_create_role_htmx_includes_success_toast(auth_client_owner, test_organiz assert "Role created successfully" in response.text -def test_delete_role_htmx_includes_success_toast(auth_client_owner, test_organization, session): +def test_delete_role_htmx_includes_success_toast( + auth_client_owner, test_organization, session +): from utils.core.models import Role + custom_role = Role(name="ToDeleteToast", organization_id=test_organization.id) session.add(custom_role) session.commit() @@ -801,8 +839,11 @@ def test_delete_role_htmx_includes_success_toast(auth_client_owner, test_organiz assert "Role deleted successfully" in response.text -def test_update_role_htmx_includes_success_toast(auth_client_owner, test_organization, session): +def test_update_role_htmx_includes_success_toast( + auth_client_owner, test_organization, session +): from utils.core.models import Role + custom_role = Role(name="RenameMe", organization_id=test_organization.id) session.add(custom_role) session.commit() @@ -821,12 +862,15 @@ def test_update_role_htmx_includes_success_toast(auth_client_owner, test_organiz assert "Role updated successfully" in response.text -def test_update_role_htmx_triggers_modal_cleanup(auth_client_owner, test_organization, session): +def test_update_role_htmx_triggers_modal_cleanup( + auth_client_owner, test_organization, session +): """The response must include an HX-Trigger header so the client can dismiss the Bootstrap modal and its backdrop. The OOB swap for #role-modals-container replaces the modal element before afterRequest fires, leaving the backdrop stuck on screen.""" from utils.core.models import Role + custom_role = Role(name="TriggerRole", organization_id=test_organization.id) session.add(custom_role) session.commit() @@ -959,6 +1003,7 @@ def test_remove_user_htmx_includes_success_toast( # 7 — Architectural guard: ban hx-on::after-request in templates # --------------------------------------------------------------------------- + def test_no_templates_use_hx_on_after_request(): """In HTMX 2.0 afterRequest fires BEFORE OOB swaps, so any handler on an element that is replaced by an OOB swap will silently fail. @@ -966,7 +1011,7 @@ def test_no_templates_use_hx_on_after_request(): import pathlib import re - attr_pattern = re.compile(r'hx-on::after-request=|hx-on:htmx:after-request=') + attr_pattern = re.compile(r"hx-on::after-request=|hx-on:htmx:after-request=") templates_dir = pathlib.Path(__file__).resolve().parent.parent / "templates" violations = [] @@ -1019,7 +1064,9 @@ def test_flash_cookie_value_is_valid_json_decodable_by_js(): decoded = unquote(cookie_part) # Must be parseable as JSON parsed = json.loads(decoded) - assert parsed["message"] == "Email address verified and added to your account." + assert ( + parsed["message"] == "Email address verified and added to your account." + ) assert parsed["level"] == "success" return diff --git a/tests/test_templates.py b/tests/test_templates.py index 8788eb2..ccbabeb 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -20,11 +20,11 @@ def test_no_missing_context_variables(missing_context_variables): def test_valid_endpoints(validate_endpoints): """Test that url_for() calls in templates reference valid FastAPI endpoints.""" from main import app + errors = validate_endpoints(app) assert not errors, errors - # --------------------------------------------------------------------------- # HTMX-specific template assertions (Phase 1-5) # --------------------------------------------------------------------------- @@ -33,7 +33,9 @@ def test_valid_endpoints(validate_endpoints): def test_base_template_includes_htmx(): content = Path("templates/base.html").read_text() assert "htmx.org" in content, "base.html must load the HTMX library" - assert 'src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"' in content + assert ( + 'src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"' in content + ) def test_base_template_includes_toast_container(): @@ -55,15 +57,18 @@ def test_toast_partial_exists(): assert Path("templates/base/partials/toast.html").is_file() -@pytest.mark.parametrize("partial", [ - "organization/partials/roles_table.html", - "organization/partials/role_row.html", - "organization/partials/members_table.html", - "organization/partials/member_row.html", - "organization/partials/invitations_list.html", - "users/partials/profile_display.html", - "users/partials/profile_form.html", -]) +@pytest.mark.parametrize( + "partial", + [ + "organization/partials/roles_table.html", + "organization/partials/role_row.html", + "organization/partials/members_table.html", + "organization/partials/member_row.html", + "organization/partials/invitations_list.html", + "users/partials/profile_display.html", + "users/partials/profile_form.html", + ], +) def test_organization_partial_exists(partial): path = Path("templates") / partial assert path.is_file(), f"Missing partial: {partial}" @@ -95,12 +100,16 @@ def test_invite_member_form_has_hx_post(): def test_edit_organization_form_has_hx_post(): - content = Path("templates/organization/modals/edit_organization_modal.html").read_text() + content = Path( + "templates/organization/modals/edit_organization_modal.html" + ).read_text() assert "hx-post" in content def test_delete_organization_form_has_hx_post(): - content = Path("templates/organization/modals/delete_organization_modal.html").read_text() + content = Path( + "templates/organization/modals/delete_organization_modal.html" + ).read_text() assert "hx-post" in content @@ -121,25 +130,25 @@ def setup_method(self): def test_mobile_profile_link_exists(self): """Mobile-only Profile link should exist with d-lg-none visibility.""" - assert 'd-lg-none' in self.content + assert "d-lg-none" in self.content # There should be a mobile-only nav item linking to profile - assert 'mobile-nav-profile' in self.content + assert "mobile-nav-profile" in self.content def test_mobile_logout_link_exists(self): """Mobile-only Logout link should exist with d-lg-none visibility.""" - assert 'mobile-nav-logout' in self.content + assert "mobile-nav-logout" in self.content def test_desktop_dropdown_hidden_on_mobile(self): """The profile dropdown should be hidden on mobile (d-none d-lg-flex).""" # The dropdown container should have classes to hide on mobile - assert 'd-lg-flex' in self.content + assert "d-lg-flex" in self.content # Verify the dropdown is inside a container hidden on mobile - assert 'd-none d-lg-flex' in self.content + assert "d-none d-lg-flex" in self.content def test_mobile_nav_items_inside_collapsible(self): """Mobile nav items should be inside the navbarContent collapsible.""" # Find the collapsible section and verify mobile nav items are inside it collapse_start = self.content.index('id="navbarContent"') collapse_section = self.content[collapse_start:] - assert 'mobile-nav-profile' in collapse_section - assert 'mobile-nav-logout' in collapse_section + assert "mobile-nav-profile" in collapse_section + assert "mobile-nav-logout" in collapse_section diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 7965efb..6b7a6c4 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -1,3 +1,3 @@ """ This file marks tests.utils as a Python package. -""" \ No newline at end of file +""" diff --git a/tests/utils/test_auth.py b/tests/utils/test_auth.py index 45fad49..9cc80eb 100644 --- a/tests/utils/test_auth.py +++ b/tests/utils/test_auth.py @@ -68,6 +68,7 @@ def test_invalid_token_type(env_vars) -> None: decoded = validate_token(access_token, "refresh") assert decoded is None + def test_password_reset_url_generation(env_vars) -> None: """ Tests that the password reset URL is correctly formatted and contains @@ -94,6 +95,7 @@ def test_password_reset_url_generation(env_vars) -> None: assert query_params["email"][0] == test_email assert query_params["token"][0] == test_token + def test_password_pattern() -> None: """ Tests that the password pattern is correctly defined. to require at least @@ -110,7 +112,7 @@ def test_password_pattern() -> None: "special": special_characters, "uppercase": uppercase_letters, "lowercase": lowercase_letters, - "digit": digits + "digit": digits, } # Valid password tests @@ -121,8 +123,10 @@ def test_password_pattern() -> None: if other_element != element: password += random.choice(required_elements[other_element]) # Randomize the order of the characters in the string - password = ''.join(random.sample(password, len(password))) - assert re.match(COMPILED_PASSWORD_PATTERN, password) is not None, f"Password {password} does not match the pattern" + password = "".join(random.sample(password, len(password))) + assert re.match(COMPILED_PASSWORD_PATTERN, password) is not None, ( + f"Password {password} does not match the pattern" + ) # Invalid password tests @@ -149,4 +153,3 @@ def test_password_pattern() -> None: # No special character password = "aA1" * 3 assert re.match(COMPILED_PASSWORD_PATTERN, password) is None - diff --git a/tests/utils/test_db.py b/tests/utils/test_db.py index 2c0f9c7..4f01274 100644 --- a/tests/utils/test_db.py +++ b/tests/utils/test_db.py @@ -10,7 +10,14 @@ tear_down_db, set_up_db, ) -from utils.core.models import Account, AccountEmail, Role, Permission, Organization, RolePermissionLink +from utils.core.models import ( + Account, + AccountEmail, + Role, + Permission, + Organization, + RolePermissionLink, +) from utils.core.auth import get_password_hash from utils.core.enums import ValidPermissions from utils.app.enums import AppPermissions @@ -30,8 +37,18 @@ def test_get_connection_url(env_vars): def test_get_connection_url_direct_mode(monkeypatch): """Test that direct mode uses standard DB vars.""" # Clear any existing vars - for var in ["USE_POOL", "DB_HOST", "DB_PORT", "DB_NAME", "DB_USER", "DB_PASSWORD", - "DB_POOL_PORT", "DB_POOL_NAME", "DB_APPUSER", "DB_APPUSER_PASSWORD"]: + for var in [ + "USE_POOL", + "DB_HOST", + "DB_PORT", + "DB_NAME", + "DB_USER", + "DB_PASSWORD", + "DB_POOL_PORT", + "DB_POOL_NAME", + "DB_APPUSER", + "DB_APPUSER_PASSWORD", + ]: monkeypatch.delenv(var, raising=False) # Set direct mode vars @@ -52,8 +69,18 @@ def test_get_connection_url_direct_mode(monkeypatch): def test_get_connection_url_pooled_mode(monkeypatch): """Test that pooled mode uses pool-specific vars.""" # Clear any existing vars - for var in ["USE_POOL", "DB_HOST", "DB_PORT", "DB_NAME", "DB_USER", "DB_PASSWORD", - "DB_POOL_PORT", "DB_POOL_NAME", "DB_APPUSER", "DB_APPUSER_PASSWORD"]: + for var in [ + "USE_POOL", + "DB_HOST", + "DB_PORT", + "DB_NAME", + "DB_USER", + "DB_PASSWORD", + "DB_POOL_PORT", + "DB_POOL_NAME", + "DB_APPUSER", + "DB_APPUSER_PASSWORD", + ]: monkeypatch.delenv(var, raising=False) # Set pooled mode vars @@ -76,8 +103,18 @@ def test_get_connection_url_pooled_mode(monkeypatch): def test_get_connection_url_missing_direct_vars(monkeypatch): """Test that missing direct mode vars raises ValueError.""" # Clear all DB vars - for var in ["USE_POOL", "DB_HOST", "DB_PORT", "DB_NAME", "DB_USER", "DB_PASSWORD", - "DB_POOL_PORT", "DB_POOL_NAME", "DB_APPUSER", "DB_APPUSER_PASSWORD"]: + for var in [ + "USE_POOL", + "DB_HOST", + "DB_PORT", + "DB_NAME", + "DB_USER", + "DB_PASSWORD", + "DB_POOL_PORT", + "DB_POOL_NAME", + "DB_APPUSER", + "DB_APPUSER_PASSWORD", + ]: monkeypatch.delenv(var, raising=False) with pytest.raises(ValueError, match="Missing environment variables"): @@ -87,8 +124,18 @@ def test_get_connection_url_missing_direct_vars(monkeypatch): def test_get_connection_url_missing_pool_vars(monkeypatch): """Test that missing pooled mode vars raises ValueError.""" # Clear all DB vars - for var in ["USE_POOL", "DB_HOST", "DB_PORT", "DB_NAME", "DB_USER", "DB_PASSWORD", - "DB_POOL_PORT", "DB_POOL_NAME", "DB_APPUSER", "DB_APPUSER_PASSWORD"]: + for var in [ + "USE_POOL", + "DB_HOST", + "DB_PORT", + "DB_NAME", + "DB_USER", + "DB_PASSWORD", + "DB_POOL_PORT", + "DB_POOL_NAME", + "DB_APPUSER", + "DB_APPUSER_PASSWORD", + ]: monkeypatch.delenv(var, raising=False) monkeypatch.setenv("USE_POOL", "1") @@ -131,8 +178,7 @@ def test_create_default_roles(session: Session, test_organization: Organization) roles = create_default_roles(session, test_organization.id) session.commit() else: - raise SetupError( - "Test setup failed; test_organization.id is None") + raise SetupError("Test setup failed; test_organization.id is None") # Verify roles were created assert len(roles) == 3 # Owner, Administrator, Member @@ -157,7 +203,8 @@ def test_create_default_roles(session: Session, test_organization: Organization) # Admin should have all permissions except DELETE_ORGANIZATION assert len(admin_permissions) == len(all_perms) - 1 assert str(ValidPermissions.DELETE_ORGANIZATION) not in { - p.name for p in admin_permissions} + p.name for p in admin_permissions + } def test_assign_permissions_to_role(session: Session, test_organization: Organization): @@ -190,10 +237,14 @@ def test_assign_permissions_to_role(session: Session, test_organization: Organiz assert len(db_permissions) == 2 assert {p.name for p in db_permissions} == { - str(ValidPermissions.CREATE_ROLE), str(ValidPermissions.DELETE_ROLE)} + str(ValidPermissions.CREATE_ROLE), + str(ValidPermissions.DELETE_ROLE), + } -def test_assign_permissions_to_role_duplicate_check(session: Session, test_organization: Organization): +def test_assign_permissions_to_role_duplicate_check( + session: Session, test_organization: Organization +): """Test that assign_permissions_to_role doesn't create duplicates""" # Create a test role with the organization from fixture role = Role(name="Test Role", organization_id=test_organization.id) @@ -212,10 +263,9 @@ def test_assign_permissions_to_role_duplicate_check(session: Session, test_organ # Verify only one assignment exists link_count = session.exec( - select(RolePermissionLink) - .where( + select(RolePermissionLink).where( RolePermissionLink.role_id == role.id, - RolePermissionLink.permission_id == perm.id + RolePermissionLink.permission_id == perm.id, ) ).all() assert len(link_count) == 1 @@ -250,7 +300,11 @@ def test_set_up_db_creates_tables(engine: Engine, session: Session): # Check that private tables ARE in the private schema private_table_names = inspector.get_table_names(schema="private") - expected_private_tables = {"account", "passwordresettoken", "emailverificationtoken"} + expected_private_tables = { + "account", + "passwordresettoken", + "emailverificationtoken", + } assert expected_private_tables.issubset(set(private_table_names)) # Verify permissions were created @@ -269,7 +323,9 @@ def test_private_tables_in_private_schema(engine: Engine): """Account, PasswordResetToken, and EmailVerificationToken must be in the private schema.""" inspector = inspect(engine) private_tables = set(inspector.get_table_names(schema="private")) - assert {"account", "passwordresettoken", "emailverificationtoken"}.issubset(private_tables) + assert {"account", "passwordresettoken", "emailverificationtoken"}.issubset( + private_tables + ) def test_public_tables_in_public_schema(engine: Engine): @@ -301,8 +357,12 @@ def test_set_up_db_drop_flag(engine: Engine, session: Session): set_up_db(drop=False) # Verify organization exists - assert session.exec(select(Organization).where( - Organization.name == "Test Organization")).first() is not None + assert ( + session.exec( + select(Organization).where(Organization.name == "Test Organization") + ).first() + is not None + ) # --- Seed AccountEmail Tests --- @@ -311,8 +371,12 @@ def test_set_up_db_drop_flag(engine: Engine, session: Session): def test_seed_creates_account_email_for_existing_accounts(session: Session): """Test that seed_account_emails creates AccountEmail rows for existing accounts.""" # Create accounts without AccountEmail rows - account1 = Account(email="seed1@example.com", hashed_password=get_password_hash("Test123!@#")) - account2 = Account(email="seed2@example.com", hashed_password=get_password_hash("Test123!@#")) + account1 = Account( + email="seed1@example.com", hashed_password=get_password_hash("Test123!@#") + ) + account2 = Account( + email="seed2@example.com", hashed_password=get_password_hash("Test123!@#") + ) session.add(account1) session.add(account2) session.commit() @@ -334,7 +398,9 @@ def test_seed_creates_account_email_for_existing_accounts(session: Session): def test_seed_is_idempotent(session: Session): """Test that running seed_account_emails twice doesn't create duplicates.""" - account = Account(email="idempotent@example.com", hashed_password=get_password_hash("Test123!@#")) + account = Account( + email="idempotent@example.com", hashed_password=get_password_hash("Test123!@#") + ) session.add(account) session.commit() diff --git a/tests/utils/test_dependencies.py b/tests/utils/test_dependencies.py index 78a5f7b..aeb40c6 100644 --- a/tests/utils/test_dependencies.py +++ b/tests/utils/test_dependencies.py @@ -1,17 +1,32 @@ from unittest.mock import MagicMock, patch from datetime import datetime, timedelta, UTC -from utils.core.models import Account, AccountRecoveryToken, User, PasswordResetToken, Role +from utils.core.models import ( + Account, + AccountRecoveryToken, + User, + PasswordResetToken, + Role, +) from utils.core.dependencies import ( validate_token_and_get_account, - get_account_from_credentials, get_account_from_tokens, get_authenticated_account, - validate_token_and_get_user, get_user_from_tokens, get_authenticated_user, - get_optional_user, get_account_from_reset_token, get_account_from_recovery_token, + get_account_from_credentials, + get_account_from_tokens, + get_authenticated_account, + validate_token_and_get_user, + get_user_from_tokens, + get_authenticated_user, + get_optional_user, + get_account_from_reset_token, + get_account_from_recovery_token, get_user_with_relations, - require_unauthenticated_client, get_verified_account + require_unauthenticated_client, + get_verified_account, ) from exceptions.http_exceptions import ( - AlreadyAuthenticatedError, AuthenticationError, CredentialsError, - PasswordValidationError + AlreadyAuthenticatedError, + AuthenticationError, + CredentialsError, + PasswordValidationError, ) from exceptions.exceptions import NeedsNewTokens import pytest @@ -25,20 +40,28 @@ def test_validate_token_and_get_account() -> None: mock_account = Account(id=1, email="test@example.com") # Test with valid access token - with patch('utils.core.dependencies.validate_token') as mock_validate: + with patch("utils.core.dependencies.validate_token") as mock_validate: mock_validate.return_value = {"sub": "test@example.com", "type": "access"} session.exec.return_value.first.return_value = mock_account - account, access_token, refresh_token = validate_token_and_get_account("valid_token", "access", session) + account, access_token, refresh_token = validate_token_and_get_account( + "valid_token", "access", session + ) assert account == mock_account assert access_token is None assert refresh_token is None mock_validate.assert_called_once_with("valid_token", token_type="access") # Test with valid refresh token (JTI validated, not revoked) - with patch('utils.core.dependencies.validate_token') as mock_validate: - with patch('utils.core.dependencies.create_access_token') as mock_access_token: - with patch('utils.core.dependencies.create_tracked_refresh_token') as mock_tracked_refresh: - mock_validate.return_value = {"sub": "test@example.com", "type": "refresh", "jti": "test-jti"} + with patch("utils.core.dependencies.validate_token") as mock_validate: + with patch("utils.core.dependencies.create_access_token") as mock_access_token: + with patch( + "utils.core.dependencies.create_tracked_refresh_token" + ) as mock_tracked_refresh: + mock_validate.return_value = { + "sub": "test@example.com", + "type": "refresh", + "jti": "test-jti", + } mock_access_token.return_value = "new_access_token" mock_tracked_refresh.return_value = "new_refresh_token" @@ -47,55 +70,79 @@ def test_validate_token_and_get_account() -> None: mock_db_token.revoked = False # First call returns account, second returns db_token - session.exec.return_value.first.side_effect = [mock_account, mock_db_token] - - account, access_token, refresh_token = validate_token_and_get_account("valid_token", "refresh", session) + session.exec.return_value.first.side_effect = [ + mock_account, + mock_db_token, + ] + + account, access_token, refresh_token = validate_token_and_get_account( + "valid_token", "refresh", session + ) assert account == mock_account assert access_token == "new_access_token" assert refresh_token == "new_refresh_token" assert mock_db_token.revoked is True - mock_tracked_refresh.assert_called_once_with(1, "test@example.com", session) + mock_tracked_refresh.assert_called_once_with( + 1, "test@example.com", session + ) # Test with refresh token missing JTI (legacy token) - with patch('utils.core.dependencies.validate_token') as mock_validate: + with patch("utils.core.dependencies.validate_token") as mock_validate: mock_validate.return_value = {"sub": "test@example.com", "type": "refresh"} session.exec.return_value.first.side_effect = None session.exec.return_value.first.return_value = mock_account - account, access_token, refresh_token = validate_token_and_get_account("valid_token", "refresh", session) + account, access_token, refresh_token = validate_token_and_get_account( + "valid_token", "refresh", session + ) assert account is None assert access_token is None assert refresh_token is None # Test with revoked refresh token (reuse detection) - with patch('utils.core.dependencies.validate_token') as mock_validate: - with patch('utils.core.dependencies.revoke_all_refresh_tokens') as mock_revoke_all: - mock_validate.return_value = {"sub": "test@example.com", "type": "refresh", "jti": "revoked-jti"} + with patch("utils.core.dependencies.validate_token") as mock_validate: + with patch( + "utils.core.dependencies.revoke_all_refresh_tokens" + ) as mock_revoke_all: + mock_validate.return_value = { + "sub": "test@example.com", + "type": "refresh", + "jti": "revoked-jti", + } mock_db_token = MagicMock() mock_db_token.account_id = 1 mock_db_token.revoked = True session.exec.return_value.first.side_effect = [mock_account, mock_db_token] - account, access_token, refresh_token = validate_token_and_get_account("valid_token", "refresh", session) + account, access_token, refresh_token = validate_token_and_get_account( + "valid_token", "refresh", session + ) assert account is None assert access_token is None assert refresh_token is None mock_revoke_all.assert_called_once_with(1, session) # Test with invalid token - with patch('utils.core.dependencies.validate_token') as mock_validate: + with patch("utils.core.dependencies.validate_token") as mock_validate: mock_validate.return_value = None session.exec.return_value.first.side_effect = None - account, access_token, refresh_token = validate_token_and_get_account("invalid_token", "access", session) + account, access_token, refresh_token = validate_token_and_get_account( + "invalid_token", "access", session + ) assert account is None assert access_token is None assert refresh_token is None # Test with valid token but no account found - with patch('utils.core.dependencies.validate_token') as mock_validate: - mock_validate.return_value = {"sub": "nonexistent@example.com", "type": "access"} + with patch("utils.core.dependencies.validate_token") as mock_validate: + mock_validate.return_value = { + "sub": "nonexistent@example.com", + "type": "access", + } session.exec.return_value.first.side_effect = None session.exec.return_value.first.return_value = None - account, access_token, refresh_token = validate_token_and_get_account("valid_token", "access", session) + account, access_token, refresh_token = validate_token_and_get_account( + "valid_token", "access", session + ) assert account is None assert access_token is None assert refresh_token is None @@ -106,23 +153,27 @@ def test_get_account_from_credentials() -> None: Tests retrieving an account using credentials. """ session = MagicMock() - mock_account = Account(id=1, email="test@example.com", hashed_password="hashed_password") + mock_account = Account( + id=1, email="test@example.com", hashed_password="hashed_password" + ) session.exec.return_value.first.return_value = mock_account - + # Test with valid credentials - with patch('utils.core.dependencies.verify_password') as mock_verify: + with patch("utils.core.dependencies.verify_password") as mock_verify: mock_verify.return_value = True - account, returned_session = get_account_from_credentials("test@example.com", "password123", session) + account, returned_session = get_account_from_credentials( + "test@example.com", "password123", session + ) assert account == mock_account assert returned_session == session mock_verify.assert_called_once_with("password123", "hashed_password") - + # Test with invalid password - with patch('utils.core.dependencies.verify_password') as mock_verify: + with patch("utils.core.dependencies.verify_password") as mock_verify: mock_verify.return_value = False with pytest.raises(CredentialsError): get_account_from_credentials("test@example.com", "wrong_password", session) - + # Test with non-existent account session.exec.return_value.first.return_value = None with pytest.raises(CredentialsError): @@ -134,46 +185,60 @@ def test_get_account_from_tokens() -> None: Tests retrieving an account from tokens. """ session = MagicMock() - + # Test with valid access token - with patch('utils.core.dependencies.validate_token_and_get_account') as mock_validate: + with patch( + "utils.core.dependencies.validate_token_and_get_account" + ) as mock_validate: mock_account = Account(id=1, email="test@example.com") mock_validate.return_value = (mock_account, None, None) - - account, access_token, refresh_token = get_account_from_tokens(("valid_access", "valid_refresh"), session) + + account, access_token, refresh_token = get_account_from_tokens( + ("valid_access", "valid_refresh"), session + ) assert account == mock_account assert access_token is None assert refresh_token is None mock_validate.assert_called_once_with("valid_access", "access", session) - + # Test with invalid access token but valid refresh token - with patch('utils.core.dependencies.validate_token_and_get_account') as mock_validate: + with patch( + "utils.core.dependencies.validate_token_and_get_account" + ) as mock_validate: mock_account = Account(id=1, email="test@example.com") # First call returns None (invalid access token) # Second call returns account and new tokens (valid refresh token) mock_validate.side_effect = [ (None, None, None), - (mock_account, "new_access", "new_refresh") + (mock_account, "new_access", "new_refresh"), ] - - account, access_token, refresh_token = get_account_from_tokens(("invalid_access", "valid_refresh"), session) + + account, access_token, refresh_token = get_account_from_tokens( + ("invalid_access", "valid_refresh"), session + ) assert account == mock_account assert access_token == "new_access" assert refresh_token == "new_refresh" assert mock_validate.call_count == 2 - + # Test with both tokens invalid - with patch('utils.core.dependencies.validate_token_and_get_account') as mock_validate: + with patch( + "utils.core.dependencies.validate_token_and_get_account" + ) as mock_validate: mock_validate.return_value = (None, None, None) - - account, access_token, refresh_token = get_account_from_tokens(("invalid_access", "invalid_refresh"), session) + + account, access_token, refresh_token = get_account_from_tokens( + ("invalid_access", "invalid_refresh"), session + ) assert account is None assert access_token is None assert refresh_token is None assert mock_validate.call_count == 2 - + # Test with no tokens - account, access_token, refresh_token = get_account_from_tokens((None, None), session) + account, access_token, refresh_token = get_account_from_tokens( + (None, None), session + ) assert account is None assert access_token is None assert refresh_token is None @@ -185,31 +250,33 @@ def test_get_authenticated_account() -> None: """ session = MagicMock() tokens = ("access_token", "refresh_token") - + # Test with valid account, no new tokens - with patch('utils.core.dependencies.get_account_from_tokens') as mock_get_account: + with patch("utils.core.dependencies.get_account_from_tokens") as mock_get_account: mock_account = Account(id=1, email="test@example.com") mock_get_account.return_value = (mock_account, None, None) - + account = get_authenticated_account(tokens, session) assert account == mock_account - + # Test with valid account, new tokens needed - with patch('utils.core.dependencies.get_account_from_tokens') as mock_get_account: - mock_account = Account(id=1, email="test@example.com", user=User(id=1, name="Test User")) + with patch("utils.core.dependencies.get_account_from_tokens") as mock_get_account: + mock_account = Account( + id=1, email="test@example.com", user=User(id=1, name="Test User") + ) mock_get_account.return_value = (mock_account, "new_access", "new_refresh") - + with pytest.raises(NeedsNewTokens) as exc_info: get_authenticated_account(tokens, session) - + assert exc_info.value.user == mock_account.user assert exc_info.value.access_token == "new_access" assert exc_info.value.refresh_token == "new_refresh" - + # Test with no valid account - with patch('utils.core.dependencies.get_account_from_tokens') as mock_get_account: + with patch("utils.core.dependencies.get_account_from_tokens") as mock_get_account: mock_get_account.return_value = (None, None, None) - + with pytest.raises(AuthenticationError): get_authenticated_account(tokens, session) @@ -224,34 +291,54 @@ def test_validate_token_and_get_user() -> None: mock_account = Account(id=1, email="test@example.com", user=mock_user) # Test with valid access token - with patch('utils.core.dependencies.validate_token_and_get_account') as mock_validate: + with patch( + "utils.core.dependencies.validate_token_and_get_account" + ) as mock_validate: mock_validate.return_value = (mock_account, None, None) - user, access_token, refresh_token = validate_token_and_get_user("valid_token", "access", session) + user, access_token, refresh_token = validate_token_and_get_user( + "valid_token", "access", session + ) assert user == mock_user assert access_token is None assert refresh_token is None # Test with valid refresh token (returns new tokens) - with patch('utils.core.dependencies.validate_token_and_get_account') as mock_validate: - mock_validate.return_value = (mock_account, "new_access_token", "new_refresh_token") - user, access_token, refresh_token = validate_token_and_get_user("valid_token", "refresh", session) + with patch( + "utils.core.dependencies.validate_token_and_get_account" + ) as mock_validate: + mock_validate.return_value = ( + mock_account, + "new_access_token", + "new_refresh_token", + ) + user, access_token, refresh_token = validate_token_and_get_user( + "valid_token", "refresh", session + ) assert user == mock_user assert access_token == "new_access_token" assert refresh_token == "new_refresh_token" # Test with invalid token - with patch('utils.core.dependencies.validate_token_and_get_account') as mock_validate: + with patch( + "utils.core.dependencies.validate_token_and_get_account" + ) as mock_validate: mock_validate.return_value = (None, None, None) - user, access_token, refresh_token = validate_token_and_get_user("invalid_token", "access", session) + user, access_token, refresh_token = validate_token_and_get_user( + "invalid_token", "access", session + ) assert user is None assert access_token is None assert refresh_token is None # Test with valid token and account but no user - with patch('utils.core.dependencies.validate_token_and_get_account') as mock_validate: + with patch( + "utils.core.dependencies.validate_token_and_get_account" + ) as mock_validate: mock_account_no_user = Account(id=1, email="test@example.com", user=None) mock_validate.return_value = (mock_account_no_user, None, None) - user, access_token, refresh_token = validate_token_and_get_user("valid_token", "access", session) + user, access_token, refresh_token = validate_token_and_get_user( + "valid_token", "access", session + ) assert user is None assert access_token is None assert refresh_token is None @@ -262,44 +349,50 @@ def test_get_user_from_tokens() -> None: Tests retrieving a user from tokens. """ session = MagicMock() - + # Test with valid access token - with patch('utils.core.dependencies.validate_token_and_get_user') as mock_validate: + with patch("utils.core.dependencies.validate_token_and_get_user") as mock_validate: mock_user = User(id=1, name="Test User") mock_validate.return_value = (mock_user, None, None) - - user, access_token, refresh_token = get_user_from_tokens(("valid_access", "valid_refresh"), session) + + user, access_token, refresh_token = get_user_from_tokens( + ("valid_access", "valid_refresh"), session + ) assert user == mock_user assert access_token is None assert refresh_token is None mock_validate.assert_called_once_with("valid_access", "access", session) - + # Test with invalid access token but valid refresh token - with patch('utils.core.dependencies.validate_token_and_get_user') as mock_validate: + with patch("utils.core.dependencies.validate_token_and_get_user") as mock_validate: mock_user = User(id=1, name="Test User") # First call returns None (invalid access token) # Second call returns user and new tokens (valid refresh token) mock_validate.side_effect = [ (None, None, None), - (mock_user, "new_access", "new_refresh") + (mock_user, "new_access", "new_refresh"), ] - - user, access_token, refresh_token = get_user_from_tokens(("invalid_access", "valid_refresh"), session) + + user, access_token, refresh_token = get_user_from_tokens( + ("invalid_access", "valid_refresh"), session + ) assert user == mock_user assert access_token == "new_access" assert refresh_token == "new_refresh" assert mock_validate.call_count == 2 - + # Test with both tokens invalid - with patch('utils.core.dependencies.validate_token_and_get_user') as mock_validate: + with patch("utils.core.dependencies.validate_token_and_get_user") as mock_validate: mock_validate.return_value = (None, None, None) - - user, access_token, refresh_token = get_user_from_tokens(("invalid_access", "invalid_refresh"), session) + + user, access_token, refresh_token = get_user_from_tokens( + ("invalid_access", "invalid_refresh"), session + ) assert user is None assert access_token is None assert refresh_token is None assert mock_validate.call_count == 2 - + # Test with no tokens user, access_token, refresh_token = get_user_from_tokens((None, None), session) assert user is None @@ -313,31 +406,31 @@ def test_get_authenticated_user() -> None: """ session = MagicMock() tokens = ("access_token", "refresh_token") - + # Test with valid user, no new tokens - with patch('utils.core.dependencies.get_user_from_tokens') as mock_get_user: + with patch("utils.core.dependencies.get_user_from_tokens") as mock_get_user: mock_user = User(id=1, name="Test User") mock_get_user.return_value = (mock_user, None, None) - + user = get_authenticated_user(tokens, session) assert user == mock_user - + # Test with valid user, new tokens needed - with patch('utils.core.dependencies.get_user_from_tokens') as mock_get_user: + with patch("utils.core.dependencies.get_user_from_tokens") as mock_get_user: mock_user = User(id=1, name="Test User") mock_get_user.return_value = (mock_user, "new_access", "new_refresh") - + with pytest.raises(NeedsNewTokens) as exc_info: get_authenticated_user(tokens, session) - + assert exc_info.value.user == mock_user assert exc_info.value.access_token == "new_access" assert exc_info.value.refresh_token == "new_refresh" - + # Test with no valid user - with patch('utils.core.dependencies.get_user_from_tokens') as mock_get_user: + with patch("utils.core.dependencies.get_user_from_tokens") as mock_get_user: mock_get_user.return_value = (None, None, None) - + with pytest.raises(AuthenticationError): get_authenticated_user(tokens, session) @@ -348,31 +441,31 @@ def test_get_optional_user() -> None: """ session = MagicMock() tokens = ("access_token", "refresh_token") - + # Test with valid user, no new tokens - with patch('utils.core.dependencies.get_user_from_tokens') as mock_get_user: + with patch("utils.core.dependencies.get_user_from_tokens") as mock_get_user: mock_user = User(id=1, name="Test User") mock_get_user.return_value = (mock_user, None, None) - + user = get_optional_user(tokens, session) assert user == mock_user - + # Test with valid user, new tokens needed - with patch('utils.core.dependencies.get_user_from_tokens') as mock_get_user: + with patch("utils.core.dependencies.get_user_from_tokens") as mock_get_user: mock_user = User(id=1, name="Test User") mock_get_user.return_value = (mock_user, "new_access", "new_refresh") - + with pytest.raises(NeedsNewTokens) as exc_info: get_optional_user(tokens, session) - + assert exc_info.value.user == mock_user assert exc_info.value.access_token == "new_access" assert exc_info.value.refresh_token == "new_refresh" - + # Test with no valid user - with patch('utils.core.dependencies.get_user_from_tokens') as mock_get_user: + with patch("utils.core.dependencies.get_user_from_tokens") as mock_get_user: mock_get_user.return_value = (None, None, None) - + user = get_optional_user(tokens, session) assert user is None @@ -389,17 +482,21 @@ def test_get_account_from_reset_token() -> None: account_id=1, token="valid_token", expires_at=datetime.now(UTC) + timedelta(hours=1), - used=False + used=False, ) session.exec.return_value.first.return_value = (mock_account, mock_token) - account, token = get_account_from_reset_token("test@example.com", "valid_token", session) + account, token = get_account_from_reset_token( + "test@example.com", "valid_token", session + ) assert account == mock_account assert token == mock_token # Test invalid token session.exec.return_value.first.return_value = None - account, token = get_account_from_reset_token("test@example.com", "invalid_token", session) + account, token = get_account_from_reset_token( + "test@example.com", "invalid_token", session + ) assert account is None assert token is None @@ -410,28 +507,24 @@ def test_get_user_with_relations() -> None: """ session = MagicMock() mock_user = User(id=1, name="Test User") - + # Create a mock user with loaded relationships mock_eager_user = User( - id=1, - name="Test User", - roles=[ - Role(id=1, name="Admin", organization_id=1) - ] + id=1, name="Test User", roles=[Role(id=1, name="Admin", organization_id=1)] ) - + session.exec.return_value.one.return_value = mock_eager_user - + # Test getting user with relations user = get_user_with_relations(mock_user, session) assert user == mock_eager_user - + # Verify the query was constructed correctly session.exec.assert_called_once() # We can't easily check the exact query construction with selectinload, # but we can verify the where clause was applied correctly assert '"user".id' in str(session.exec.call_args[0][0]) - assert 'id_1' in str(session.exec.call_args[0][0]) + assert "id_1" in str(session.exec.call_args[0][0]) def test_require_unauthenticated_client() -> None: @@ -453,11 +546,10 @@ def test_get_verified_account() -> None: ) # Test with matching email and correct password - with patch('utils.core.dependencies.verify_password') as mock_verify: + with patch("utils.core.dependencies.verify_password") as mock_verify: mock_verify.return_value = True account = get_verified_account( - email="test@example.com", password="correct_password", - account=mock_account + email="test@example.com", password="correct_password", account=mock_account ) assert account == mock_account mock_verify.assert_called_once_with("correct_password", "hashed_password") @@ -465,18 +557,18 @@ def test_get_verified_account() -> None: # Test with mismatched email with pytest.raises(CredentialsError) as exc_info: get_verified_account( - email="wrong@example.com", password="correct_password", - account=mock_account + email="wrong@example.com", password="correct_password", account=mock_account ) assert "Email does not match" in str(exc_info.value.detail) # Test with wrong password - with patch('utils.core.dependencies.verify_password') as mock_verify: + with patch("utils.core.dependencies.verify_password") as mock_verify: mock_verify.return_value = False with pytest.raises(PasswordValidationError) as exc_info: get_verified_account( - email="test@example.com", password="wrong_password", - account=mock_account + email="test@example.com", + password="wrong_password", + account=mock_account, ) assert exc_info.value.detail["field"] == "password" @@ -529,4 +621,4 @@ def test_get_account_from_recovery_token_invalid() -> None: account, token = get_account_from_recovery_token("nonexistent", session) assert account is None - assert token is None \ No newline at end of file + assert token is None diff --git a/tests/utils/test_images.py b/tests/utils/test_images.py index 3e627a6..7b13db6 100644 --- a/tests/utils/test_images.py +++ b/tests/utils/test_images.py @@ -6,96 +6,107 @@ InvalidImageError, MAX_FILE_SIZE, MIN_DIMENSION, - MAX_DIMENSION + MAX_DIMENSION, ) -def create_test_image(width: int, height: int, format: str = 'PNG') -> bytes: + +def create_test_image(width: int, height: int, format: str = "PNG") -> bytes: """Helper function to create test images""" - image = Image.new('RGB', (width, height), color='red') + image = Image.new("RGB", (width, height), color="red") output = io.BytesIO() image.save(output, format=format) return output.getvalue() + def test_webp_dependencies_are_installed(): """Test that webp dependencies are installed""" - assert '.webp' in Image.registered_extensions(), "WebP dependencies are not installed (e.g., libwebp-dev on Linux)" + assert ".webp" in Image.registered_extensions(), ( + "WebP dependencies are not installed (e.g., libwebp-dev on Linux)" + ) + def test_valid_square_image(): """Test processing a valid square image""" image_data = create_test_image(500, 500) - processed_data, content_type = validate_and_process_image(image_data, 'image/png') - + processed_data, content_type = validate_and_process_image(image_data, "image/png") + # Verify the processed image processed_image = Image.open(io.BytesIO(processed_data)) assert processed_image.size == (500, 500) - assert content_type == 'image/png' + assert content_type == "image/png" + def test_valid_rectangular_image(): """Test processing a valid rectangular image""" image_data = create_test_image(800, 600) - processed_data, content_type = validate_and_process_image(image_data, 'image/png') - + processed_data, content_type = validate_and_process_image(image_data, "image/png") + # Verify the processed image processed_image = Image.open(io.BytesIO(processed_data)) assert processed_image.size == (600, 600) - assert content_type == 'image/png' + assert content_type == "image/png" + def test_minimum_size_image(): """Test processing an image with minimum allowed dimensions""" image_data = create_test_image(MIN_DIMENSION, MIN_DIMENSION) - processed_data, content_type = validate_and_process_image(image_data, 'image/png') - + processed_data, content_type = validate_and_process_image(image_data, "image/png") + processed_image = Image.open(io.BytesIO(processed_data)) assert processed_image.size == (100, 100) + def test_too_small_image(): """Test that too small images are rejected""" image_data = create_test_image(MIN_DIMENSION - 1, MIN_DIMENSION - 1) with pytest.raises(InvalidImageError) as exc_info: - validate_and_process_image(image_data, 'image/png') + validate_and_process_image(image_data, "image/png") assert "Image too small" in str(exc_info.value.detail) + def test_too_large_image(): """Test that too large images are rejected""" image_data = create_test_image(MAX_DIMENSION + 1, MAX_DIMENSION + 1) with pytest.raises(InvalidImageError) as exc_info: - validate_and_process_image(image_data, 'image/png') + validate_and_process_image(image_data, "image/png") assert "Image too large" in str(exc_info.value.detail) + def test_invalid_file_type(): """Test that invalid file types are rejected""" image_data = create_test_image(500, 500) with pytest.raises(InvalidImageError) as exc_info: - validate_and_process_image(image_data, 'image/gif') + validate_and_process_image(image_data, "image/gif") assert "Invalid file type" in str(exc_info.value.detail) + def test_file_too_large(): """Test that files exceeding MAX_FILE_SIZE are rejected""" # Create a large file that exceeds MAX_FILE_SIZE - large_image_data = b'0' * (MAX_FILE_SIZE + 1) + large_image_data = b"0" * (MAX_FILE_SIZE + 1) with pytest.raises(InvalidImageError) as exc_info: - validate_and_process_image(large_image_data, 'image/png') + validate_and_process_image(large_image_data, "image/png") assert "File too large" in str(exc_info.value.detail) + def test_corrupt_image_data(): """Test that corrupt image data is rejected""" - corrupt_data = b'not an image' + corrupt_data = b"not an image" with pytest.raises(InvalidImageError) as exc_info: - validate_and_process_image(corrupt_data, 'image/png') + validate_and_process_image(corrupt_data, "image/png") assert "Invalid image file" in str(exc_info.value.detail) + def test_different_image_formats(): """Test processing different valid image formats""" - formats = [ - ('JPEG', 'image/jpeg'), - ('PNG', 'image/png'), - ('WEBP', 'image/webp') - ] - + formats = [("JPEG", "image/jpeg"), ("PNG", "image/png"), ("WEBP", "image/webp")] + for format_name, content_type in formats: image_data = create_test_image(500, 500, format_name) - processed_data, result_type = validate_and_process_image(image_data, content_type) - + processed_data, result_type = validate_and_process_image( + image_data, content_type + ) + # Verify the processed image processed_image = Image.open(io.BytesIO(processed_data)) assert processed_image.size == (500, 500) diff --git a/tests/utils/test_models.py b/tests/utils/test_models.py index 4da569d..1d57dd5 100644 --- a/tests/utils/test_models.py +++ b/tests/utils/test_models.py @@ -33,7 +33,14 @@ def test_private_models_have_private_schema(): def test_public_models_not_in_private_schema(): """Public models must not be placed in the 'private' schema.""" - for model in (User, Organization, Role, Permission, UserRoleLink, RolePermissionLink): + for model in ( + User, + Organization, + Role, + Permission, + UserRoleLink, + RolePermissionLink, + ): assert model.__table__.schema != "private", ( f"{model.__name__} should not be in the 'private' schema" ) @@ -53,7 +60,7 @@ def test_permissions_persist_after_role_deletion(session: Session): session.add(organization) session.commit() session.refresh(organization) - + # Ensure organization ID is not None (for type checking) if organization.id is None: pytest.fail("Organization ID is None, test setup failed.") @@ -66,9 +73,11 @@ def test_permissions_persist_after_role_deletion(session: Session): # Find specific permissions to link delete_org_permission = next( - p for p in all_permissions if p.name == ValidPermissions.DELETE_ORGANIZATION) + p for p in all_permissions if p.name == ValidPermissions.DELETE_ORGANIZATION + ) edit_org_permission = next( - p for p in all_permissions if p.name == ValidPermissions.EDIT_ORGANIZATION) + p for p in all_permissions if p.name == ValidPermissions.EDIT_ORGANIZATION + ) role.permissions.append(delete_org_permission) role.permissions.append(edit_org_permission) @@ -93,7 +102,9 @@ def test_permissions_persist_after_role_deletion(session: Session): assert len(remaining_role_permissions) == 0 -def test_user_organizations_property(session: Session, test_user: User, test_organization: Organization): +def test_user_organizations_property( + session: Session, test_user: User, test_organization: Organization +): """ Test that User.organizations property correctly returns all organizations the user belongs to via their roles. @@ -101,7 +112,7 @@ def test_user_organizations_property(session: Session, test_user: User, test_org # Ensure organization ID is not None (for type checking) if test_organization.id is None: pytest.fail("Organization ID is None, test setup failed.") - + # Create a role in the test organization role = Role(name="Test Role", organization_id=test_organization.id) session.add(role) @@ -118,7 +129,9 @@ def test_user_organizations_property(session: Session, test_user: User, test_org assert test_user.organizations[0].id == test_organization.id -def test_organization_users_property(session: Session, test_user: User, test_organization: Organization): +def test_organization_users_property( + session: Session, test_user: User, test_organization: Organization +): """ Test that Organization.users property correctly returns all users in the organization via their roles. @@ -126,7 +139,7 @@ def test_organization_users_property(session: Session, test_user: User, test_org # Ensure organization ID is not None (for type checking) if test_organization.id is None: pytest.fail("Organization ID is None, test setup failed.") - + # Create a role in the test organization role = Role(name="Test Role", organization_id=test_organization.id) session.add(role) @@ -145,7 +158,9 @@ def test_organization_users_property(session: Session, test_user: User, test_org assert test_user in users_list -def test_cascade_delete_organization(session: Session, test_user: User, test_organization: Organization): +def test_cascade_delete_organization( + session: Session, test_user: User, test_organization: Organization +): """ Test that deleting an organization cascades properly: - Deletes associated roles @@ -155,7 +170,7 @@ def test_cascade_delete_organization(session: Session, test_user: User, test_org # Ensure organization ID is not None (for type checking) if test_organization.id is None: pytest.fail("Organization ID is None, test setup failed.") - + # Create a role in the test organization role = Role(name="Test Role", organization_id=test_organization.id) session.add(role) @@ -210,15 +225,13 @@ def test_password_reset_token_is_expired(session: Session, test_account: Account """ # Create an expired token expired_token = PasswordResetToken( - account_id=test_account.id, - expires_at=datetime.now(UTC) - timedelta(hours=1) + account_id=test_account.id, expires_at=datetime.now(UTC) - timedelta(hours=1) ) session.add(expired_token) # Create a valid token valid_token = PasswordResetToken( - account_id=test_account.id, - expires_at=datetime.now(UTC) + timedelta(hours=1) + account_id=test_account.id, expires_at=datetime.now(UTC) + timedelta(hours=1) ) session.add(valid_token) session.commit() @@ -228,7 +241,9 @@ def test_password_reset_token_is_expired(session: Session, test_account: Account assert not valid_token.is_expired() -def test_user_has_permission(session: Session, test_user: User, test_organization: Organization): +def test_user_has_permission( + session: Session, test_user: User, test_organization: Organization +): """ Test that User.has_permission method correctly checks if a user has a specific permission for a given organization. @@ -236,7 +251,7 @@ def test_user_has_permission(session: Session, test_user: User, test_organizatio # Ensure organization ID is not None (for type checking) if test_organization.id is None: pytest.fail("Organization ID is None, test setup failed.") - + # Create a role with specific permissions in the test organization role = Role(name="Test Role", organization_id=test_organization.id) session.add(role) @@ -245,20 +260,19 @@ def test_user_has_permission(session: Session, test_user: User, test_organizatio # Assign permissions to the role delete_org_permission: Optional[Permission] = session.exec( - select(Permission).where(Permission.name == - ValidPermissions.DELETE_ORGANIZATION) + select(Permission).where( + Permission.name == ValidPermissions.DELETE_ORGANIZATION + ) ).first() edit_org_permission: Optional[Permission] = session.exec( - select(Permission).where(Permission.name == - ValidPermissions.EDIT_ORGANIZATION) + select(Permission).where(Permission.name == ValidPermissions.EDIT_ORGANIZATION) ).first() if delete_org_permission is not None and edit_org_permission is not None: role.permissions.append(delete_org_permission) role.permissions.append(edit_org_permission) else: - raise SetupError( - "Test setup failed; permission not found in database") + raise SetupError("Test setup failed; permission not found in database") session.commit() # Link the user to the role @@ -267,36 +281,56 @@ def test_user_has_permission(session: Session, test_user: User, test_organizatio session.refresh(test_user) # Test the has_permission method - assert test_user.has_permission( - ValidPermissions.DELETE_ORGANIZATION, test_organization) is True - assert test_user.has_permission( - ValidPermissions.EDIT_ORGANIZATION, test_organization) is True - assert test_user.has_permission( - ValidPermissions.INVITE_USER, test_organization) is False + assert ( + test_user.has_permission( + ValidPermissions.DELETE_ORGANIZATION, test_organization + ) + is True + ) + assert ( + test_user.has_permission(ValidPermissions.EDIT_ORGANIZATION, test_organization) + is True + ) + assert ( + test_user.has_permission(ValidPermissions.INVITE_USER, test_organization) + is False + ) -def test_cascade_delete_account_deletes_user(session: Session, test_account: Account, test_user: User): +def test_cascade_delete_account_deletes_user( + session: Session, test_account: Account, test_user: User +): """ Test that deleting an account cascades to delete the associated user """ # Verify the user exists - assert session.exec(select(User).where(User.account_id == test_account.id)).first() is not None - + assert ( + session.exec(select(User).where(User.account_id == test_account.id)).first() + is not None + ) + # Delete the account session.delete(test_account) session.commit() - + # Verify the user was cascade deleted - assert session.exec(select(User).where(User.account_id == test_account.id)).first() is None + assert ( + session.exec(select(User).where(User.account_id == test_account.id)).first() + is None + ) -def test_role_name_unique_per_organization(session: Session, test_organization: Organization, second_test_organization: Organization): +def test_role_name_unique_per_organization( + session: Session, + test_organization: Organization, + second_test_organization: Organization, +): """ - Test that role names must be unique within the same organization, + Test that role names must be unique within the same organization, but can be duplicated across different organizations. """ role_name = "UniqueRoleTest" - + # Ensure organization IDs are not None (for type checking) if test_organization.id is None or second_test_organization.id is None: pytest.fail("Organization ID is None, test setup failed.") @@ -313,25 +347,31 @@ def test_role_name_unique_per_organization(session: Session, test_organization: session.add(role2_duplicate) with pytest.raises(IntegrityError): session.commit() - + # Rollback the session after the expected error session.rollback() # Create a role with the same name in the second organization (should succeed) - role3_different_org = Role(name=role_name, organization_id=second_test_organization.id) + role3_different_org = Role( + name=role_name, organization_id=second_test_organization.id + ) session.add(role3_different_org) session.commit() session.refresh(role3_different_org) assert role3_different_org.id is not None # Verify the final state - roles_org1 = session.exec(select(Role).where(Role.organization_id == test_organization.id)).all() - roles_org2 = session.exec(select(Role).where(Role.organization_id == second_test_organization.id)).all() + roles_org1 = session.exec( + select(Role).where(Role.organization_id == test_organization.id) + ).all() + roles_org2 = session.exec( + select(Role).where(Role.organization_id == second_test_organization.id) + ).all() # Org 1 should have exactly one role with the test name after the failed attempt count_org1_roles_with_name = sum(1 for role in roles_org1 if role.name == role_name) assert count_org1_roles_with_name == 1 - + # Org 2 should only have the one role we added assert len(roles_org2) == 1 assert roles_org2[0].name == role_name diff --git a/tests/utils/test_rate_limit.py b/tests/utils/test_rate_limit.py index e82fae0..f45bae7 100644 --- a/tests/utils/test_rate_limit.py +++ b/tests/utils/test_rate_limit.py @@ -10,6 +10,7 @@ # RateLimitWindow — core behaviour # --------------------------------------------------------------------------- + def test_allows_requests_under_limit(): limiter = RateLimitWindow(max_attempts=3, window_seconds=60) for _ in range(3): @@ -63,6 +64,7 @@ def test_reset_nonexistent_key_is_noop(): # Window expiry # --------------------------------------------------------------------------- + def test_unblocks_after_window_expiry(): """Simulate time passing so that old attempts fall outside the window.""" limiter = RateLimitWindow(max_attempts=2, window_seconds=10) @@ -86,7 +88,7 @@ def test_unblocks_after_window_expiry(): def test_retry_after_is_positive_and_decreases(): limiter = RateLimitWindow(max_attempts=1, window_seconds=30) - base_time = time.monotonic() + base_time = 1_000_000.0 with patch("utils.core.rate_limit.time.monotonic", return_value=base_time): limiter.record("key") @@ -106,6 +108,7 @@ def test_retry_after_is_positive_and_decreases(): # Prune # --------------------------------------------------------------------------- + def test_prune_removes_stale_keys(): limiter = RateLimitWindow(max_attempts=2, window_seconds=10) diff --git a/utils/app/enums.py b/utils/app/enums.py index 892c6ad..cfdea59 100644 --- a/utils/app/enums.py +++ b/utils/app/enums.py @@ -6,6 +6,7 @@ during database setup, and can be used with User.has_permission() in the same way as core permissions. """ + from enum import StrEnum diff --git a/utils/app/models.py b/utils/app/models.py index fb066e8..f947e72 100644 --- a/utils/app/models.py +++ b/utils/app/models.py @@ -25,6 +25,7 @@ class OrganizationResource(SQLModel, table=True): resources, users with WRITE_ORGANIZATION_RESOURCES can create/edit them, and users with DELETE_ORGANIZATION_RESOURCES can delete them. """ + id: Optional[int] = Field(default=None, primary_key=True) organization_id: int = Field(foreign_key="organization.id", index=True) title: str diff --git a/utils/core/auth.py b/utils/core/auth.py index 3bf1bac..1a6bbda 100644 --- a/utils/core/auth.py +++ b/utils/core/auth.py @@ -13,7 +13,13 @@ from fastapi.templating import Jinja2Templates from fastapi import Cookie from utils.core.db import create_engine, get_connection_url -from utils.core.models import AccountRecoveryToken, EmailVerificationToken, PasswordResetToken, RefreshToken, Account +from utils.core.models import ( + AccountRecoveryToken, + EmailVerificationToken, + PasswordResetToken, + RefreshToken, + Account, +) logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -29,11 +35,11 @@ ACCESS_TOKEN_EXPIRE_MINUTES = 30 REFRESH_TOKEN_EXPIRE_DAYS = 30 PASSWORD_PATTERN_COMPONENTS = [ - r"(?=.*\d)", # At least one digit - r"(?=.*[a-z])", # At least one lowercase letter - r"(?=.*[A-Z])", # At least one uppercase letter + r"(?=.*\d)", # At least one digit + r"(?=.*[a-z])", # At least one lowercase letter + r"(?=.*[A-Z])", # At least one uppercase letter r"(?=.*[\[\]\\@$!%*?&{}<>.,'#\-_=+\(\):;|~/\^])", # At least one special character - r".{8,}" # At least 8 characters long + r".{8,}", # At least 8 characters long ] COMPILED_PASSWORD_PATTERN = re.compile(r"".join(PASSWORD_PATTERN_COMPONENTS)) @@ -45,14 +51,14 @@ def convert_python_regex_to_html(regex: str) -> str: """ # Map each special char to its escaped form special_map = { - '{': r'\{', - '}': r'\}', - '<': r'\<', - '>': r'\>', - '.': r'\.', - '+': r'\+', - '|': r'\|', - ',': r'\,', + "{": r"\{", + "}": r"\}", + "<": r"\<", + ">": r"\>", + ".": r"\.", + "+": r"\+", + "|": r"\|", + ",": r"\,", "'": r"\\'", # doubly escaped single quote "/": r"\/", } @@ -94,17 +100,17 @@ def get_password_hash(password: str) -> str: Hash a password using bcrypt with a random salt """ # Convert the password to bytes and generate the hash - password_bytes = password.encode('utf-8') + password_bytes = password.encode("utf-8") salt = gensalt() - return hashpw(password_bytes, salt).decode('utf-8') + return hashpw(password_bytes, salt).decode("utf-8") def verify_password(plain_password: str, hashed_password: str) -> bool: """ Verify a password against a bcrypt hash """ - password_bytes = plain_password.encode('utf-8') - hashed_bytes = hashed_password.encode('utf-8') + password_bytes = plain_password.encode("utf-8") + hashed_bytes = hashed_password.encode("utf-8") return checkpw(password_bytes, hashed_bytes) @@ -114,14 +120,15 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) - if expires_delta: expire = datetime.now(UTC) + expires_delta else: - expire = datetime.now( - UTC) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + expire = datetime.now(UTC) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, os.getenv("SECRET_KEY"), algorithm=ALGORITHM) return encoded_jwt -def create_refresh_token(data: dict, jti: str, expires_delta: Optional[timedelta] = None) -> str: +def create_refresh_token( + data: dict, jti: str, expires_delta: Optional[timedelta] = None +) -> str: to_encode = data.copy() to_encode.update({"type": "refresh", "jti": jti}) if expires_delta: @@ -150,7 +157,7 @@ def revoke_all_refresh_tokens(account_id: int, session: Session) -> None: tokens = session.exec( select(RefreshToken).where( RefreshToken.account_id == account_id, - RefreshToken.revoked == False # noqa: E712 + RefreshToken.revoked == False, # noqa: E712 ) ).all() for token in tokens: @@ -170,7 +177,9 @@ def cleanup_expired_refresh_tokens(session: Session) -> int: def validate_token(token: str, token_type: str = "access") -> Optional[dict]: try: - decoded_token = jwt.decode(token, os.getenv("SECRET_KEY"), algorithms=[ALGORITHM]) + decoded_token = jwt.decode( + token, os.getenv("SECRET_KEY"), algorithms=[ALGORITHM] + ) # Check if the token has expired if decoded_token["exp"] < datetime.now(UTC).timestamp(): @@ -198,23 +207,22 @@ def generate_password_reset_url(email: str, token: str) -> str: Returns: Complete password reset URL """ - base_url = os.getenv('BASE_URL') + base_url = os.getenv("BASE_URL") return f"{base_url}/account/reset_password?email={email}&token={token}" def send_reset_email(email: str, session: Session) -> None: # Check for an existing unexpired token - account: Optional[Account] = session.exec(select(Account).where( - Account.email == email - )).first() - + account: Optional[Account] = session.exec( + select(Account).where(Account.email == email) + ).first() + if account: existing_token = session.exec( - select(PasswordResetToken) - .where( + select(PasswordResetToken).where( PasswordResetToken.account_id == account.id, PasswordResetToken.expires_at > datetime.now(UTC), - PasswordResetToken.used == False # noqa: E712 - SQL expression for boolean false + PasswordResetToken.used == False, # noqa: E712 - SQL expression for boolean false ) ).first() @@ -225,15 +233,15 @@ def send_reset_email(email: str, session: Session) -> None: # Generate a new token token: str = str(uuid.uuid4()) reset_token: PasswordResetToken = PasswordResetToken( - account_id=account.id, token=token) + account_id=account.id, token=token + ) session.add(reset_token) try: reset_url: str = generate_password_reset_url(email, token) # Render the email template - template: Template = templates.get_template( - "emails/reset_email.html") + template: Template = templates.get_template("emails/reset_email.html") html_content: str = template.render({"reset_url": reset_url}) resend.api_key = os.getenv("RESEND_API_KEY") @@ -275,7 +283,7 @@ def send_reset_email_task(email: str) -> None: def generate_email_verification_url(token: str) -> str: """Generates the email verification URL.""" - base_url = os.getenv('BASE_URL') + base_url = os.getenv("BASE_URL") return f"{base_url}/account/emails/verify?token={token}" @@ -286,12 +294,11 @@ def send_email_verification(account_id: int, new_email: str, session: Session) - """ # Check for existing unexpired token for this account+email existing_token = session.exec( - select(EmailVerificationToken) - .where( + select(EmailVerificationToken).where( EmailVerificationToken.account_id == account_id, EmailVerificationToken.new_email == new_email, EmailVerificationToken.expires_at > datetime.now(UTC), - EmailVerificationToken.used == False # noqa: E712 + EmailVerificationToken.used == False, # noqa: E712 ) ).first() @@ -351,15 +358,19 @@ def send_email_verified_notification(primary_email: str, new_email: str) -> None logger.error(f"Failed to send email verified notification: {e}") -def send_primary_email_changed_notification(old_email: str, new_email: str, recovery_url: str) -> None: +def send_primary_email_changed_notification( + old_email: str, new_email: str, recovery_url: str +) -> None: """Send a notification to the old primary email that primary was changed.""" try: template: Template = templates.get_template("emails/primary_email_changed.html") - html_content: str = template.render({ - "old_email": old_email, - "new_email": new_email, - "recovery_url": recovery_url, - }) + html_content: str = template.render( + { + "old_email": old_email, + "new_email": new_email, + "recovery_url": recovery_url, + } + ) resend.api_key = os.getenv("RESEND_API_KEY") params = { @@ -379,10 +390,12 @@ def send_email_removed_notification(removed_email: str, recovery_url: str) -> No """Send a notification to the removed email address.""" try: template: Template = templates.get_template("emails/email_removed_alert.html") - html_content: str = template.render({ - "removed_email": removed_email, - "recovery_url": recovery_url, - }) + html_content: str = template.render( + { + "removed_email": removed_email, + "recovery_url": recovery_url, + } + ) resend.api_key = os.getenv("RESEND_API_KEY") params = { @@ -403,7 +416,7 @@ def send_email_removed_notification(removed_email: str, recovery_url: str) -> No def generate_recovery_url(token: str) -> str: """Generates the account recovery URL.""" - base_url = os.getenv('BASE_URL') + base_url = os.getenv("BASE_URL") return f"{base_url}/account/recover?token={token}" @@ -414,8 +427,7 @@ def create_recovery_token(account_id: int, email: str, session: Session) -> str: If an unexpired token already exists for the same account+email, returns it. """ existing = session.exec( - select(AccountRecoveryToken) - .where( + select(AccountRecoveryToken).where( AccountRecoveryToken.account_id == account_id, AccountRecoveryToken.email == email, AccountRecoveryToken.expires_at > datetime.now(UTC), diff --git a/utils/core/db.py b/utils/core/db.py index bacaf68..fac531e 100644 --- a/utils/core/db.py +++ b/utils/core/db.py @@ -4,7 +4,13 @@ from typing import Union, Sequence from sqlalchemy.engine import URL from sqlmodel import create_engine, Session, SQLModel, select, text -from utils.core.models import Account, AccountEmail, Role, Permission, RolePermissionLink +from utils.core.models import ( + Account, + AccountEmail, + Role, + Permission, + RolePermissionLink, +) from utils.core.enums import ValidPermissions from utils.app.enums import AppPermissions from utils.app.models import * # noqa: F401, F403 — registers app models with SQLModel.metadata @@ -33,7 +39,7 @@ def ensure_database_exists(url: URL) -> None: ).scalar() if not exists: conn.execute(text(f'CREATE DATABASE "{dbname}"')) - + def get_connection_url() -> URL: """ @@ -75,7 +81,13 @@ def get_connection_url() -> URL: database = os.getenv("DB_POOL_NAME") username = os.getenv("DB_APPUSER") password = os.getenv("DB_APPUSER_PASSWORD") - required = ["DB_HOST", "DB_POOL_PORT", "DB_POOL_NAME", "DB_APPUSER", "DB_APPUSER_PASSWORD"] + required = [ + "DB_HOST", + "DB_POOL_PORT", + "DB_POOL_NAME", + "DB_APPUSER", + "DB_APPUSER_PASSWORD", + ] else: port = os.getenv("DB_PORT") database = os.getenv("DB_NAME") @@ -87,12 +99,18 @@ def get_connection_url() -> URL: if missing: raise ValueError(f"Missing environment variables: {', '.join(missing)}") + assert port is not None + assert database is not None + assert username is not None + assert password is not None + assert host is not None + database_url: URL = URL.create( drivername="postgresql", username=username, password=password, host=host, - port=int(port), # type: ignore[arg-type] + port=int(port), database=database, query={"sslmode": sslmode}, ) @@ -101,10 +119,10 @@ def get_connection_url() -> URL: def assign_permissions_to_role( - session: Session, - role: Role, - permissions: Union[list[Permission], Sequence[Permission]], - check_first: bool = False + session: Session, + role: Role, + permissions: Union[list[Permission], Sequence[Permission]], + check_first: bool = False, ) -> None: """ Assigns permissions to a role in the database. @@ -122,7 +140,7 @@ def assign_permissions_to_role( db_role_permission_link: RolePermissionLink | None = session.exec( select(RolePermissionLink).where( RolePermissionLink.role_id == role.id, - RolePermissionLink.permission_id == permission.id + RolePermissionLink.permission_id == permission.id, ) ).first() else: @@ -131,13 +149,14 @@ def assign_permissions_to_role( # Skip granting DELETE_ORGANIZATION permission to the Administrator role if not db_role_permission_link: role_permission_link = RolePermissionLink( - role_id=role.id, - permission_id=permission.id + role_id=role.id, permission_id=permission.id ) session.add(role_permission_link) -def create_default_roles(session: Session, organization_id: int, check_first: bool = True) -> list: +def create_default_roles( + session: Session, organization_id: int, check_first: bool = True +) -> list: """ Creates default roles for a specified organization in the database if they do not already exist, and assigns permissions to the Owner and Administrator roles. @@ -155,8 +174,7 @@ def create_default_roles(session: Session, organization_id: int, check_first: bo for role_name in default_roles: db_role = session.exec( select(Role).where( - Role.name == role_name, - Role.organization_id == organization_id + Role.name == role_name, Role.organization_id == organization_id ) ).first() if not db_role: @@ -168,22 +186,24 @@ def create_default_roles(session: Session, organization_id: int, check_first: bo # Fetch all permissions once owner_permissions = session.exec(select(Permission)).all() admin_permissions = [ - permission for permission in owner_permissions + permission + for permission in owner_permissions if permission.name != ValidPermissions.DELETE_ORGANIZATION ] # Get Owner and Administrator roles by name owner_role = next(role for role in roles_in_db if role.name == "Owner") - admin_role = next( - role for role in roles_in_db if role.name == "Administrator") + admin_role = next(role for role in roles_in_db if role.name == "Administrator") # Assign all permissions to Owner assign_permissions_to_role( - session, owner_role, owner_permissions, check_first=check_first) + session, owner_role, owner_permissions, check_first=check_first + ) # Assign filtered permissions to Administrator assign_permissions_to_role( - session, admin_role, admin_permissions, check_first=check_first) + session, admin_role, admin_permissions, check_first=check_first + ) session.commit() return roles_in_db @@ -198,8 +218,9 @@ def create_permissions(session: Session) -> None: session (Session): The database session to use for operations. """ for permission in chain(ValidPermissions, AppPermissions): - db_permission = session.exec(select(Permission).where( - Permission.name == str(permission))).first() + db_permission = session.exec( + select(Permission).where(Permission.name == str(permission)) + ).first() if not db_permission: db_permission = Permission(name=str(permission)) session.add(db_permission) @@ -211,6 +232,7 @@ def seed_account_emails(session: Session) -> None: Each account gets a primary, verified AccountEmail matching its email field. """ from datetime import datetime, UTC + accounts = session.exec(select(Account)).all() for account in accounts: existing = session.exec( diff --git a/utils/core/dependencies.py b/utils/core/dependencies.py index 1101a02..17ec682 100644 --- a/utils/core/dependencies.py +++ b/utils/core/dependencies.py @@ -6,14 +6,29 @@ from datetime import UTC, datetime from typing import Optional, Tuple, Generator from utils.core.auth import ( - validate_token, create_access_token, create_tracked_refresh_token, - revoke_all_refresh_tokens, oauth2_scheme_cookie, verify_password + validate_token, + create_access_token, + create_tracked_refresh_token, + revoke_all_refresh_tokens, + oauth2_scheme_cookie, + verify_password, ) from utils.core.db import create_engine, get_connection_url -from utils.core.models import User, Role, AccountRecoveryToken, PasswordResetToken, EmailVerificationToken, RefreshToken, Account +from utils.core.models import ( + User, + Role, + AccountRecoveryToken, + PasswordResetToken, + EmailVerificationToken, + RefreshToken, + Account, +) from exceptions.http_exceptions import ( - AlreadyAuthenticatedError, AuthenticationError, CredentialsError, - DataIntegrityError, PasswordValidationError + AlreadyAuthenticatedError, + AuthenticationError, + CredentialsError, + DataIntegrityError, + PasswordValidationError, ) from exceptions.exceptions import NeedsNewTokens @@ -33,9 +48,7 @@ def get_session() -> Generator[Session, None, None]: def validate_token_and_get_account( - token: str, - token_type: str, - session: Session + token: str, token_type: str, session: Session ) -> tuple[Optional[Account], Optional[str], Optional[str]]: """ Validates a token and returns the associated account if valid. @@ -53,9 +66,9 @@ def validate_token_and_get_account( if decoded_token: user_email = decoded_token.get("sub") - account = session.exec(select(Account).where( - Account.email == user_email - )).first() + account = session.exec( + select(Account).where(Account.email == user_email) + ).first() if account: assert account.id is not None @@ -84,10 +97,10 @@ def validate_token_and_get_account( # Revoke the current token and issue new ones db_token.revoked = True - new_access_token = create_access_token( - data={"sub": account.email}) + new_access_token = create_access_token(data={"sub": account.email}) new_refresh_token = create_tracked_refresh_token( - account.id, account.email, session) + account.id, account.email, session + ) session.commit() return account, new_access_token, new_refresh_token return account, None, None @@ -97,7 +110,7 @@ def validate_token_and_get_account( def get_account_from_credentials( email: EmailStr = Form(...), password: str = Form(...), - session: Session = Depends(get_session) + session: Session = Depends(get_session), ) -> Tuple[Account, Session]: """ Validates user credentials and returns the account if valid. @@ -113,9 +126,8 @@ def get_account_from_credentials( Raises: HTTPException: If credentials are invalid """ - account = session.exec(select(Account).where( - Account.email == email)).first() - + account = session.exec(select(Account).where(Account.email == email)).first() + if not account or not verify_password(password, account.hashed_password): raise CredentialsError() @@ -123,31 +135,34 @@ def get_account_from_credentials( def get_account_from_tokens( - tokens: tuple[Optional[str], Optional[str]], - session: Session + tokens: tuple[Optional[str], Optional[str]], session: Session ) -> tuple[Optional[Account], Optional[str], Optional[str]]: """ Attempts to get an account from access or refresh tokens. - + Args: tokens: Tuple of (access_token, refresh_token) session: Database session - + Returns: Tuple containing the account (if valid), and new tokens (if using refresh token) """ access_token, refresh_token = tokens # Try to validate the access token first - account, _, _ = validate_token_and_get_account( - access_token, "access", session) if access_token else (None, None, None) + account, _, _ = ( + validate_token_and_get_account(access_token, "access", session) + if access_token + else (None, None, None) + ) if account: return account, None, None # If access token is invalid or missing, try the refresh token if refresh_token: account, new_access_token, new_refresh_token = validate_token_and_get_account( - refresh_token, "refresh", session) + refresh_token, "refresh", session + ) if account: return account, new_access_token, new_refresh_token @@ -161,20 +176,21 @@ def get_authenticated_account( ) -> Account: """ Dependency that returns the authenticated account or raises an exception. - + Args: tokens: Tuple of (access_token, refresh_token) session: Database session - + Returns: The authenticated account - + Raises: AuthenticationError: If no valid account is found NeedsNewTokens: If using refresh token and new tokens are generated """ account, new_access_token, new_refresh_token = get_account_from_tokens( - tokens, session) + tokens, session + ) if account: if new_access_token and new_refresh_token: @@ -182,16 +198,14 @@ def get_authenticated_account( if account.user: raise NeedsNewTokens(account.user, new_access_token, new_refresh_token) else: - raise DataIntegrityError("User") + raise DataIntegrityError("User") return account raise AuthenticationError() def validate_token_and_get_user( - token: str, - token_type: str, - session: Session + token: str, token_type: str, session: Session ) -> tuple[Optional[User], Optional[str], Optional[str]]: # Delegate to validate_token_and_get_account for shared JTI logic account, new_access_token, new_refresh_token = validate_token_and_get_account( @@ -203,21 +217,24 @@ def validate_token_and_get_user( def get_user_from_tokens( - tokens: tuple[Optional[str], Optional[str]], - session: Session + tokens: tuple[Optional[str], Optional[str]], session: Session ) -> tuple[Optional[User], Optional[str], Optional[str]]: access_token, refresh_token = tokens # Try to validate the access token first - user, _, _ = validate_token_and_get_user( - access_token, "access", session) if access_token else (None, None, None) + user, _, _ = ( + validate_token_and_get_user(access_token, "access", session) + if access_token + else (None, None, None) + ) if user: return user, None, None # If access token is invalid or missing, try the refresh token if refresh_token: user, new_access_token, new_refresh_token = validate_token_and_get_user( - refresh_token, "refresh", session) + refresh_token, "refresh", session + ) if user: return user, new_access_token, new_refresh_token @@ -226,12 +243,10 @@ def get_user_from_tokens( def get_authenticated_user( - tokens: tuple[Optional[str], Optional[str] - ] = Depends(oauth2_scheme_cookie), + tokens: tuple[Optional[str], Optional[str]] = Depends(oauth2_scheme_cookie), session: Session = Depends(get_session), ) -> User: - user, new_access_token, new_refresh_token = get_user_from_tokens( - tokens, session) + user, new_access_token, new_refresh_token = get_user_from_tokens(tokens, session) if user: if new_access_token and new_refresh_token: @@ -244,12 +259,10 @@ def get_authenticated_user( # TODO: Maybe instead of an optional function, we have get_account and then # get_required_account, which just wraps it? def get_optional_user( - tokens: tuple[Optional[str], Optional[str] - ] = Depends(oauth2_scheme_cookie), - session: Session = Depends(get_session) + tokens: tuple[Optional[str], Optional[str]] = Depends(oauth2_scheme_cookie), + session: Session = Depends(get_session), ) -> Optional[User]: - user, new_access_token, new_refresh_token = get_user_from_tokens( - tokens, session) + user, new_access_token, new_refresh_token = get_user_from_tokens(tokens, session) if user: if new_access_token and new_refresh_token: @@ -271,8 +284,12 @@ def require_unauthenticated_client( def get_verified_account( - email: EmailStr = Form(..., title="Email", description="Account email address for verification"), - password: str = Form(..., title="Password", description="Account password for verification"), + email: EmailStr = Form( + ..., title="Email", description="Account email address for verification" + ), + password: str = Form( + ..., title="Password", description="Account password for verification" + ), account: Account = Depends(get_authenticated_account), ) -> Account: """ @@ -282,16 +299,12 @@ def get_verified_account( if email != account.email: raise CredentialsError(message="Email does not match authenticated account") if not verify_password(password, account.hashed_password): - raise PasswordValidationError( - field="password", - message="Password is incorrect" - ) + raise PasswordValidationError(field="password", message="Password is incorrect") return account def get_account_from_email_verification_token( - token: str, - session: Session + token: str, session: Session ) -> tuple[Optional[Account], Optional[EmailVerificationToken]]: """ Get account from an email verification token. @@ -300,12 +313,11 @@ def get_account_from_email_verification_token( Tuple of (account, token) if valid, or (None, None) if invalid """ result = session.exec( - select(Account, EmailVerificationToken) - .where( + select(Account, EmailVerificationToken).where( EmailVerificationToken.token == token, EmailVerificationToken.expires_at > datetime.now(UTC), EmailVerificationToken.used == False, # noqa: E712 - EmailVerificationToken.account_id == Account.id + EmailVerificationToken.account_id == Account.id, ) ).first() @@ -317,8 +329,7 @@ def get_account_from_email_verification_token( def get_account_from_recovery_token( - token: str, - session: Session + token: str, session: Session ) -> tuple[Optional[Account], Optional[AccountRecoveryToken]]: """ Get account from an account recovery token. @@ -327,12 +338,11 @@ def get_account_from_recovery_token( Tuple of (account, token) if valid, or (None, None) if invalid """ result = session.exec( - select(Account, AccountRecoveryToken) - .where( + select(Account, AccountRecoveryToken).where( AccountRecoveryToken.token == token, AccountRecoveryToken.expires_at > datetime.now(UTC), AccountRecoveryToken.used == False, # noqa: E712 - AccountRecoveryToken.account_id == Account.id + AccountRecoveryToken.account_id == Account.id, ) ).first() @@ -344,29 +354,26 @@ def get_account_from_recovery_token( def get_account_from_reset_token( - email: str, - token: str, - session: Session + email: str, token: str, session: Session ) -> tuple[Optional[Account], Optional[PasswordResetToken]]: """ Get account from a password reset token. - + Args: email: Email address of the account token: Password reset token session: Database session - + Returns: Tuple of (account, token) if valid, or (None, None) if invalid """ result = session.exec( - select(Account, PasswordResetToken) - .where( + select(Account, PasswordResetToken).where( Account.email == email, PasswordResetToken.token == token, PasswordResetToken.expires_at > datetime.now(UTC), PasswordResetToken.used == False, # noqa: E712 - PasswordResetToken.account_id == Account.id + PasswordResetToken.account_id == Account.id, ) ).first() @@ -390,7 +397,7 @@ def get_user_with_relations( .where(User.id == user.id) .options( selectinload(User.roles).selectinload(Role.organization), - selectinload(User.roles).selectinload(Role.permissions) + selectinload(User.roles).selectinload(Role.permissions), ) ).one() @@ -409,7 +416,9 @@ async def get_user_from_request(request: Request) -> Optional[User]: # Get a database session engine = create_engine(get_connection_url()) with Session(engine) as session: - user, new_access_token, new_refresh_token = get_user_from_tokens(tokens, session) + user, new_access_token, new_refresh_token = get_user_from_tokens( + tokens, session + ) # If we got new tokens, we'd normally raise NeedsNewTokens, but in an exception # handler we can't do that easily. For now, just return the user. @@ -423,4 +432,4 @@ async def get_user_from_request(request: Request) -> Optional[User]: # Eagerly load avatar so it's available after the session closes _ = user.avatar - return user \ No newline at end of file + return user diff --git a/utils/core/enums.py b/utils/core/enums.py index 02feff0..3828edc 100644 --- a/utils/core/enums.py +++ b/utils/core/enums.py @@ -9,6 +9,7 @@ class ValidPermissions(StrEnum): To add app-specific permissions, define them in a separate StrEnum in utils/app/enums.py (see AppPermissions for an example). """ + DELETE_ORGANIZATION = "Delete Organization" EDIT_ORGANIZATION = "Edit Organization" INVITE_USER = "Invite User" @@ -16,4 +17,4 @@ class ValidPermissions(StrEnum): EDIT_USER_ROLE = "Edit User Role" CREATE_ROLE = "Create Role" DELETE_ROLE = "Delete Role" - EDIT_ROLE = "Edit Role" \ No newline at end of file + EDIT_ROLE = "Edit Role" diff --git a/utils/core/images.py b/utils/core/images.py index 7af79c1..3fb3cf7 100644 --- a/utils/core/images.py +++ b/utils/core/images.py @@ -9,11 +9,7 @@ MAX_FILE_SIZE = 2 * 1024 * 1024 # 2MB in bytes -ALLOWED_CONTENT_TYPES = { - 'image/jpeg': 'JPEG', - 'image/png': 'PNG', - 'image/webp': 'WEBP' -} +ALLOWED_CONTENT_TYPES = {"image/jpeg": "JPEG", "image/png": "PNG", "image/webp": "WEBP"} MIN_DIMENSION = 100 MAX_DIMENSION = 2000 @@ -22,37 +18,30 @@ def validate_and_process_image( - image_data: bytes, - content_type: str | None + image_data: bytes, content_type: str | None ) -> Tuple[bytes, str]: """ Validates and processes an image file. Returns a tuple of (processed_image_data, content_type). Ensures the image is square by center-cropping. - + Raises: InvalidImageError: If the image is invalid or doesn't meet requirements """ # Check file size if len(image_data) > MAX_FILE_SIZE: - raise InvalidImageError( - message="File too large (max 2MB)" - ) + raise InvalidImageError(message="File too large (max 2MB)") # Check file type if not content_type or content_type not in ALLOWED_CONTENT_TYPES: - raise InvalidImageError( - message="Invalid file type. Must be JPEG, PNG, or WebP" - ) + raise InvalidImageError(message="Invalid file type. Must be JPEG, PNG, or WebP") try: # Open and validate image image: Image.Image = Image.open(io.BytesIO(image_data)) width, height = image.size except Exception: - raise InvalidImageError( - message="Invalid image file" - ) + raise InvalidImageError(message="Invalid image file") # Check minimum dimensions if width < MIN_DIMENSION or height < MIN_DIMENSION: @@ -72,7 +61,7 @@ def validate_and_process_image( top = (height - min_dim) // 2 right = left + min_dim bottom = top + min_dim - + image = image.crop((left, top, right, bottom)) # Get the format from the content type diff --git a/utils/core/invitations.py b/utils/core/invitations.py index 6700869..1176911 100644 --- a/utils/core/invitations.py +++ b/utils/core/invitations.py @@ -47,11 +47,11 @@ def send_invitation_email(invitation: Invitation, session: Session) -> None: try: # Ensure the organization relationship is loaded or fetch it - org_name = "the organization" # Default name + org_name = "the organization" # Default name if invitation.organization: org_name = invitation.organization.name elif invitation.organization_id: - # Attempt to fetch if not loaded - requires Organization model import + # Attempt to fetch if not loaded - requires Organization model import org = session.get(Organization, invitation.organization_id) if org: org_name = org.name @@ -62,7 +62,6 @@ def send_invitation_email(invitation: Invitation, session: Session) -> None: ) raise DataIntegrityError(resource="Organization") - invitation_link = generate_invitation_link(invitation.token) # Render the email template @@ -90,12 +89,14 @@ def send_invitation_email(invitation: Invitation, session: Session) -> None: except Exception as e: logger.error( f"Failed to send organization invitation email to {invitation.invitee_email}: {e}", - exc_info=True + exc_info=True, ) raise EmailSendFailedError() from e -def process_invitation(invitation: Invitation, accepted_by_user: User, session: Session) -> None: +def process_invitation( + invitation: Invitation, accepted_by_user: User, session: Session +) -> None: """ Processes an accepted invitation. @@ -117,7 +118,9 @@ def process_invitation(invitation: Invitation, accepted_by_user: User, session: # Note: SQLAlchemy handles the association table updates automatically here. else: # This should ideally not happen if validation is correct upstream, but handle defensively. - logger.error(f"Invitation {invitation.id} is missing associated role during processing.") + logger.error( + f"Invitation {invitation.id} is missing associated role during processing." + ) raise DataIntegrityError(resource="Invitation role") # Mark the invitation as used @@ -128,4 +131,6 @@ def process_invitation(invitation: Invitation, accepted_by_user: User, session: # Add the updated invitation to the session (will be updated on commit) session.add(invitation) # IMPORTANT: The session is NOT committed here. The calling endpoint must handle the commit. - logger.info(f"Processed invitation {invitation.id} for user {accepted_by_user.id} and role {invitation.role_id}") + logger.info( + f"Processed invitation {invitation.id} for user {accepted_by_user.id} and role {invitation.role_id}" + ) diff --git a/utils/core/models.py b/utils/core/models.py index e3c12af..7181fd0 100644 --- a/utils/core/models.py +++ b/utils/core/models.py @@ -4,7 +4,7 @@ from datetime import datetime, UTC, timedelta from typing import Optional, List, Union from pydantic import EmailStr -from sqlmodel import SQLModel, Field, Relationship, Session, select +from sqlmodel import SQLModel, Field, Relationship, Session, select, col from sqlalchemy import Column, LargeBinary, String, UniqueConstraint from sqlalchemy.orm import Mapped from exceptions.http_exceptions import DataIntegrityError @@ -35,54 +35,44 @@ class Account(SQLModel, table=True): user: Mapped[Optional["User"]] = Relationship( back_populates="account", - sa_relationship_kwargs={ - "cascade": "all, delete-orphan" - } + sa_relationship_kwargs={"cascade": "all, delete-orphan"}, ) password_reset_tokens: Mapped[List["PasswordResetToken"]] = Relationship( back_populates="account", - sa_relationship_kwargs={ - "cascade": "all, delete-orphan" - } + sa_relationship_kwargs={"cascade": "all, delete-orphan"}, ) emails: Mapped[List["AccountEmail"]] = Relationship( back_populates="account", - sa_relationship_kwargs={ - "cascade": "all, delete-orphan" - } + sa_relationship_kwargs={"cascade": "all, delete-orphan"}, ) email_verification_tokens: Mapped[List["EmailVerificationToken"]] = Relationship( back_populates="account", - sa_relationship_kwargs={ - "cascade": "all, delete-orphan" - } + sa_relationship_kwargs={"cascade": "all, delete-orphan"}, ) refresh_tokens: Mapped[List["RefreshToken"]] = Relationship( back_populates="account", - sa_relationship_kwargs={ - "cascade": "all, delete-orphan" - } + sa_relationship_kwargs={"cascade": "all, delete-orphan"}, ) account_recovery_tokens: Mapped[List["AccountRecoveryToken"]] = Relationship( back_populates="account", - sa_relationship_kwargs={ - "cascade": "all, delete-orphan" - } + sa_relationship_kwargs={"cascade": "all, delete-orphan"}, ) + class PasswordResetToken(SQLModel, table=True): __table_args__ = {"schema": "private"} id: Optional[int] = Field(default=None, primary_key=True) account_id: Optional[int] = Field(foreign_key="private.account.id") - token: str = Field(default_factory=lambda: str( - uuid4()), index=True, unique=True) + token: str = Field(default_factory=lambda: str(uuid4()), index=True, unique=True) expires_at: datetime = Field( - default_factory=lambda: datetime.now(UTC) + timedelta(hours=1)) + default_factory=lambda: datetime.now(UTC) + timedelta(hours=1) + ) used: bool = Field(default=False) account: Mapped[Optional[Account]] = Relationship( - back_populates="password_reset_tokens") + back_populates="password_reset_tokens" + ) def is_expired(self) -> bool: """ @@ -105,9 +95,7 @@ class AccountEmail(SQLModel, table=True): verified_at: Optional[datetime] = Field(default=None) created_at: datetime = Field(default_factory=utc_now) - account: Mapped[Optional["Account"]] = Relationship( - back_populates="emails" - ) + account: Mapped[Optional["Account"]] = Relationship(back_populates="emails") class EmailVerificationToken(SQLModel, table=True): @@ -118,7 +106,8 @@ class EmailVerificationToken(SQLModel, table=True): token: str = Field(default_factory=lambda: str(uuid4()), index=True, unique=True) new_email: str expires_at: datetime = Field( - default_factory=lambda: datetime.now(UTC) + timedelta(hours=1)) + default_factory=lambda: datetime.now(UTC) + timedelta(hours=1) + ) used: bool = Field(default=False) account: Mapped[Optional["Account"]] = Relationship( @@ -137,11 +126,13 @@ class AccountRecoveryToken(SQLModel, table=True): token: str = Field(default_factory=lambda: str(uuid4()), index=True, unique=True) email: str # the email address to restore expires_at: datetime = Field( - default_factory=lambda: datetime.now(UTC) + timedelta(days=7)) + default_factory=lambda: datetime.now(UTC) + timedelta(days=7) + ) used: bool = Field(default=False) account: Mapped[Optional[Account]] = Relationship( - back_populates="account_recovery_tokens") + back_populates="account_recovery_tokens" + ) def is_expired(self) -> bool: return datetime.now(UTC) > self.expires_at.replace(tzinfo=UTC) @@ -157,9 +148,7 @@ class RefreshToken(SQLModel, table=True): revoked: bool = Field(default=False) created_at: datetime = Field(default_factory=utc_now) - account: Mapped[Optional[Account]] = Relationship( - back_populates="refresh_tokens" - ) + account: Mapped[Optional[Account]] = Relationship(back_populates="refresh_tokens") def is_expired(self) -> bool: return datetime.now(UTC) > self.expires_at.replace(tzinfo=UTC) @@ -173,14 +162,14 @@ class UserRoleLink(SQLModel, table=True): Associates users with roles. This creates a many-to-many relationship between users and roles. """ + user_id: Optional[int] = Field(foreign_key="user.id", primary_key=True) role_id: Optional[int] = Field(foreign_key="role.id", primary_key=True) class RolePermissionLink(SQLModel, table=True): role_id: Optional[int] = Field(foreign_key="role.id", primary_key=True) - permission_id: Optional[int] = Field( - foreign_key="permission.id", primary_key=True) + permission_id: Optional[int] = Field(foreign_key="permission.id", primary_key=True) class UserBase(SQLModel): @@ -206,19 +195,13 @@ class User(UserBase, table=True): updated_at: datetime = Field(default_factory=utc_now) account_id: Optional[int] = Field(foreign_key="private.account.id", unique=True) - account: Mapped[Optional[Account]] = Relationship( - back_populates="user" - ) + account: Mapped[Optional[Account]] = Relationship(back_populates="user") avatar: Mapped[Optional["UserAvatar"]] = Relationship( back_populates="user", - sa_relationship_kwargs={ - "cascade": "all, delete-orphan", - "uselist": False - } + sa_relationship_kwargs={"cascade": "all, delete-orphan", "uselist": False}, ) roles: Mapped[List["Role"]] = Relationship( - back_populates="users", - link_model=UserRoleLink + back_populates="users", link_model=UserRoleLink ) accepted_invitations: Mapped[List["Invitation"]] = Relationship( back_populates="accepted_by" @@ -237,7 +220,9 @@ def organizations(self) -> List["Organization"]: organization_ids.add(role.organization_id) return organizations - def has_permission(self, permission: StrEnum, organization: Union["Organization", int]) -> bool: + def has_permission( + self, permission: StrEnum, organization: Union["Organization", int] + ) -> bool: """ Check if the user has a specific permission for a given organization. Accepts any StrEnum (ValidPermissions, AppPermissions, etc.). @@ -265,15 +250,11 @@ class Organization(SQLModel, table=True): roles: Mapped[List["Role"]] = Relationship( back_populates="organization", - sa_relationship_kwargs={ - "cascade": "all, delete-orphan" - } + sa_relationship_kwargs={"cascade": "all, delete-orphan"}, ) invitations: Mapped[List["Invitation"]] = Relationship( back_populates="organization", - sa_relationship_kwargs={ - "cascade": "all, delete-orphan" - } + sa_relationship_kwargs={"cascade": "all, delete-orphan"}, ) @property @@ -303,52 +284,49 @@ class Role(SQLModel, table=True): created_at: Timestamp when the role was created. updated_at: Timestamp when the role was last updated. """ + id: Optional[int] = Field(default=None, primary_key=True) name: str - organization_id: int = Field( - foreign_key="organization.id") + organization_id: int = Field(foreign_key="organization.id") created_at: datetime = Field(default_factory=utc_now) updated_at: datetime = Field(default_factory=utc_now) organization: Mapped[Organization] = Relationship(back_populates="roles") users: Mapped[List[User]] = Relationship( - back_populates="roles", - link_model=UserRoleLink + back_populates="roles", link_model=UserRoleLink ) permissions: Mapped[List["Permission"]] = Relationship( - back_populates="roles", - link_model=RolePermissionLink + back_populates="roles", link_model=RolePermissionLink ) invitations: Mapped[List["Invitation"]] = Relationship( - back_populates="role", - sa_relationship_kwargs={ - "cascade": "all, delete-orphan" - } + back_populates="role", sa_relationship_kwargs={"cascade": "all, delete-orphan"} ) - + __table_args__ = ( UniqueConstraint("organization_id", "name", name="uq_role_organization_name"), ) + class Permission(SQLModel, table=True): """ Represents a permission that can be assigned to a role. Permissions are populated automatically from ValidPermissions and AppPermissions enums during database setup. """ + id: Optional[int] = Field(default=None, primary_key=True) name: str = Field(sa_column=Column(String, unique=True)) created_at: datetime = Field(default_factory=utc_now) updated_at: datetime = Field(default_factory=utc_now) roles: Mapped[List[Role]] = Relationship( - back_populates="permissions", - link_model=RolePermissionLink + back_populates="permissions", link_model=RolePermissionLink ) # --- New Invitation Model --- + class Invitation(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) organization_id: int = Field(foreign_key="organization.id", index=True) @@ -364,17 +342,24 @@ class Invitation(SQLModel, table=True): organization: "Organization" = Relationship(back_populates="invitations") role: "Role" = Relationship(back_populates="invitations") - accepted_by: Optional["User"] = Relationship( - back_populates="accepted_invitations" - ) + accepted_by: Optional["User"] = Relationship(back_populates="accepted_invitations") __table_args__ = ( - UniqueConstraint("organization_id", "invitee_email", "used", name="uq_invitation_org_email_used"), + UniqueConstraint( + "organization_id", + "invitee_email", + "used", + name="uq_invitation_org_email_used", + ), ) def is_expired(self) -> bool: """Checks if the invitation has passed its expiry date.""" - aware_expires_at = self.expires_at.replace(tzinfo=UTC) if self.expires_at.tzinfo is None else self.expires_at + aware_expires_at = ( + self.expires_at.replace(tzinfo=UTC) + if self.expires_at.tzinfo is None + else self.expires_at + ) return utc_now() > aware_expires_at def is_active(self) -> bool: @@ -382,7 +367,11 @@ def is_active(self) -> bool: return not self.used and not self.is_expired() @classmethod - def get_active_for_org(cls, session: Session, organization_id: int) -> list["Invitation"]: - statement = select(cls).where(cls.organization_id == organization_id, cls.used == False) # noqa: E712 + def get_active_for_org( + cls, session: Session, organization_id: int + ) -> list["Invitation"]: + statement = select(cls).where( + cls.organization_id == organization_id, col(cls.used).is_(False) + ) results = session.exec(statement).all() - return [inv for inv in results if not inv.is_expired()] \ No newline at end of file + return [inv for inv in results if not inv.is_expired()] diff --git a/utils/core/rate_limit.py b/utils/core/rate_limit.py index a192955..bf2cdda 100644 --- a/utils/core/rate_limit.py +++ b/utils/core/rate_limit.py @@ -47,7 +47,8 @@ def _prune_stale_keys(self, now: float) -> None: """Remove stale keys across the whole store.""" cutoff = now - self.window_seconds stale_keys = [ - key for key, timestamps in self._attempts.items() + key + for key, timestamps in self._attempts.items() if all(timestamp <= cutoff for timestamp in timestamps) ] for key in stale_keys: @@ -117,13 +118,16 @@ def prune(self) -> None: # --- Configuration helpers --- + def _int_env(name: str, default: int) -> int: val = os.environ.get(name) if val is not None: try: return int(val) except ValueError: - logger.warning(f"Invalid integer for {name}={val!r}, using default {default}") + logger.warning( + f"Invalid integer for {name}={val!r}, using default {default}" + ) return default @@ -153,6 +157,7 @@ def _int_env(name: str, default: int) -> int: # --- Dependency helpers --- + def get_client_ip(request: Request) -> str: """ Extract client IP from the request. @@ -184,6 +189,7 @@ def _enforce_rate_limit(limiter: RateLimitWindow, key: str, scope: str) -> int: # --- Per-endpoint FastAPI dependencies --- + def check_login_ip_rate_limit(request: Request) -> None: ip = get_client_ip(request) _enforce_rate_limit(login_ip_limiter, f"ip:{ip}", "login_ip") diff --git a/uv.lock b/uv.lock index 9f957c0..da22e93 100644 --- a/uv.lock +++ b/uv.lock @@ -26,14 +26,14 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] @@ -112,20 +112,20 @@ wheels = [ [[package]] name = "async-lru" -version = "2.2.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8a/ca724066c32a53fa75f59e0f21aa822fdaa8a0dffa112d223634e3caabf9/async_lru-2.2.0.tar.gz", hash = "sha256:80abae2a237dbc6c60861d621619af39f0d920aea306de34cb992c879e01370c", size = 14654, upload-time = "2026-02-20T19:11:43.848Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/5c/af990f019b8dd11c5492a6371fe74a5b0276357370030b67254a87329944/async_lru-2.2.0-py3-none-any.whl", hash = "sha256:e2c1cf731eba202b59c5feedaef14ffd9d02ad0037fcda64938699f2c380eafe", size = 7890, upload-time = "2026-02-20T19:11:42.273Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" }, ] [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] @@ -219,11 +219,11 @@ css = [ [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.5.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] [[package]] @@ -271,57 +271,82 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" -version = "3.4.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, - { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, - { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, - { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, - { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, - { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, - { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, - { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, - { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, - { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, - { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, - { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, - { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, - { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, - { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, - { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, - { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, - { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, - { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, - { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, - { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, - { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, - { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, - { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, - { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, - { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] name = "click" -version = "8.3.1" +version = "8.4.0" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, ] [[package]] @@ -361,11 +386,11 @@ wheels = [ [[package]] name = "decorator" -version = "5.2.1" +version = "5.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, ] [[package]] @@ -377,6 +402,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -410,7 +444,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.135.1" +version = "0.136.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -419,9 +453,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, ] [[package]] @@ -448,6 +482,7 @@ dev = [ { name = "graphviz" }, { name = "jupyter" }, { name = "notebook" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-jinja-check", extra = ["fastapi"] }, { name = "pytest-playwright" }, @@ -478,6 +513,7 @@ dev = [ { name = "graphviz", specifier = ">=0.20.3,<1.0.0" }, { name = "jupyter", specifier = ">=1.1.1,<2.0.0" }, { name = "notebook", specifier = ">=7.2.2,<8.0.0" }, + { name = "pre-commit", specifier = ">=4.6.0" }, { name = "pytest", specifier = ">=8.3.3,<9.0.0" }, { name = "pytest-jinja-check", extras = ["fastapi"], specifier = ">=1.0.2" }, { name = "pytest-playwright", specifier = ">=0.7.2" }, @@ -496,6 +532,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, ] +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + [[package]] name = "fqdn" version = "1.5.1" @@ -516,36 +561,58 @@ wheels = [ [[package]] name = "greenlet" -version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, - { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, - { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, - { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, - { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, - { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, - { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, - { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, - { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, - { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, - { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, - { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, - { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, - { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, - { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, - { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, - { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, + { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, + { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, + { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, + { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, + { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, + { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, ] [[package]] @@ -585,13 +652,22 @@ 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 = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "idna" -version = "3.11" +version = "3.15" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -629,7 +705,7 @@ wheels = [ [[package]] name = "ipython" -version = "9.11.0" +version = "9.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -639,13 +715,14 @@ dependencies = [ { name = "matplotlib-inline" }, { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "prompt-toolkit" }, + { name = "psutil" }, { name = "pygments" }, { name = "stack-data" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667", size = 4427354, upload-time = "2026-03-05T08:57:30.549Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/87cda5842cf5c31837c06ddb588e11c3c35d8ece89b7a0108c06b8c9b00a/ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967", size = 4430549, upload-time = "2026-04-24T12:24:55.221Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/90/45c72becc57158facc6a6404f663b77bbcea2519ca57f760e2879ae1315d/ipython-9.11.0-py3-none-any.whl", hash = "sha256:6922d5bcf944c6e525a76a0a304451b60a2b6f875e86656d8bc2dfda5d710e19", size = 624222, upload-time = "2026-03-05T08:57:28.94Z" }, + { url = "https://files.pythonhosted.org/packages/b9/86/3060e8029b7cc505cce9a0137431dda81d0a3fde93a8f0f50ee0bf37a795/ipython-9.13.0-py3-none-any.whl", hash = "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201", size = 627274, upload-time = "2026-04-24T12:24:53.038Z" }, ] [[package]] @@ -690,14 +767,14 @@ wheels = [ [[package]] name = "jedi" -version = "0.19.2" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parso" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, ] [[package]] @@ -714,20 +791,20 @@ wheels = [ [[package]] name = "json5" -version = "0.13.0" +version = "0.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/e8/a3f261a66e4663f22700bc8a17c08cb83e91fbf086726e7a228398968981/json5-0.13.0.tar.gz", hash = "sha256:b1edf8d487721c0bf64d83c28e91280781f6e21f4a797d3261c7c828d4c165bf", size = 52441, upload-time = "2026-01-01T19:42:14.99Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/4b/6f8906aaf67d501e259b0adab4d312945bb7211e8b8d4dcc77c92320edaa/json5-0.14.0.tar.gz", hash = "sha256:b3f492fad9f6cdbced8b7d40b28b9b1c9701c5f561bef0d33b81c2ff433fefcb", size = 52656, upload-time = "2026-03-27T22:50:48.108Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/9e/038522f50ceb7e74f1f991bf1b699f24b0c2bbe7c390dd36ad69f4582258/json5-0.13.0-py3-none-any.whl", hash = "sha256:9a08e1dd65f6a4d4c6fa82d216cf2477349ec2346a38fd70cc11d2557499fbcc", size = 36163, upload-time = "2026-01-01T19:42:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/b8/42/cf027b4ac873b076189d935b135397675dac80cb29acb13e1ab86ad6c631/json5-0.14.0-py3-none-any.whl", hash = "sha256:56cf861bab076b1178eb8c92e1311d273a9b9acea2ccc82c276abf839ebaef3a", size = 36271, upload-time = "2026-03-27T22:50:47.073Z" }, ] [[package]] name = "jsonpointer" -version = "3.0.0" +version = "3.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, ] [[package]] @@ -837,7 +914,7 @@ wheels = [ [[package]] name = "jupyter-events" -version = "0.12.0" +version = "0.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonschema", extra = ["format-nongpl"] }, @@ -849,26 +926,26 @@ dependencies = [ { name = "rfc3986-validator" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/c3/306d090461e4cf3cd91eceaff84bede12a8e52cd821c2d20c9a4fd728385/jupyter_events-0.12.0.tar.gz", hash = "sha256:fc3fce98865f6784c9cd0a56a20644fc6098f21c8c33834a8d9fe383c17e554b", size = 62196, upload-time = "2025-02-03T17:23:41.485Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/f8/475c4241b2b75af0deaae453ed003c6c851766dbc44d332d8baf245dc931/jupyter_events-0.12.1.tar.gz", hash = "sha256:faff25f77218335752f35f23c5fe6e4a392a7bd99a5939ccb9b8fbf594636cf3", size = 62854, upload-time = "2026-04-20T23:17:50.66Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/48/577993f1f99c552f18a0428731a755e06171f9902fa118c379eb7c04ea22/jupyter_events-0.12.0-py3-none-any.whl", hash = "sha256:6464b2fa5ad10451c3d35fabc75eab39556ae1e2853ad0c0cc31b656731a97fb", size = 19430, upload-time = "2025-02-03T17:23:38.643Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl", hash = "sha256:c366585253f537a627da52fa7ca7410c5b5301fe893f511e7b077c2d93ec8bcf", size = 19512, upload-time = "2026-04-20T23:17:48.927Z" }, ] [[package]] name = "jupyter-lsp" -version = "2.3.0" +version = "2.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jupyter-server" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/5a/9066c9f8e94ee517133cd98dba393459a16cd48bba71a82f16a65415206c/jupyter_lsp-2.3.0.tar.gz", hash = "sha256:458aa59339dc868fb784d73364f17dbce8836e906cd75fd471a325cba02e0245", size = 54823, upload-time = "2025-08-27T17:47:34.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/ff/1e4a61f5170a9a1d978f3ac3872449de6c01fc71eaf89657824c878b1549/jupyter_lsp-2.3.1.tar.gz", hash = "sha256:fdf8a4aa7d85813976d6e29e95e6a2c8f752701f926f2715305249a3829805a6", size = 55677, upload-time = "2026-04-02T08:10:06.749Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/60/1f6cee0c46263de1173894f0fafcb3475ded276c472c14d25e0280c18d6d/jupyter_lsp-2.3.0-py3-none-any.whl", hash = "sha256:e914a3cb2addf48b1c7710914771aaf1819d46b2e5a79b0f917b5478ec93f34f", size = 76687, upload-time = "2025-08-27T17:47:33.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/e8/9d61dcbd1dce8ef418f06befd4ac084b4720429c26b0b1222bc218685eff/jupyter_lsp-2.3.1-py3-none-any.whl", hash = "sha256:71b954d834e85ff3096400554f2eefaf7fe37053036f9a782b0f7c5e42dadb81", size = 77513, upload-time = "2026-04-02T08:10:01.753Z" }, ] [[package]] name = "jupyter-server" -version = "2.17.0" +version = "2.18.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -890,9 +967,9 @@ dependencies = [ { name = "traitlets" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/ac/e040ec363d7b6b1f11304cc9f209dac4517ece5d5e01821366b924a64a50/jupyter_server-2.17.0.tar.gz", hash = "sha256:c38ea898566964c888b4772ae1ed58eca84592e88251d2cfc4d171f81f7e99d5", size = 731949, upload-time = "2025-08-21T14:42:54.042Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/15/1eacb0fcb79ef86e8a0a79a708e6ad7435f6f223097dd29a4ce861fabc44/jupyter_server-2.18.2.tar.gz", hash = "sha256:06b4f40d8a7a00bb39d5216859c81374a0e7cfefe6d8a5a7facc5a5c37c679a7", size = 753177, upload-time = "2026-05-06T07:04:36.274Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/80/a24767e6ca280f5a49525d987bf3e4d7552bf67c8be07e8ccf20271f8568/jupyter_server-2.17.0-py3-none-any.whl", hash = "sha256:e8cb9c7db4251f51ed307e329b81b72ccf2056ff82d50524debde1ee1870e13f", size = 388221, upload-time = "2025-08-21T14:42:52.034Z" }, + { url = "https://files.pythonhosted.org/packages/e2/50/ecf4f70d65bdb7519b28a33d1b2fee8a4b4ba1ae1a92f15d97e877c5de21/jupyter_server-2.18.2-py3-none-any.whl", hash = "sha256:fa5e46539ded65791838035a2b6001f13e54d5f64b8b3752eb1e91fdd641a5b8", size = 391907, upload-time = "2026-05-06T07:04:34.014Z" }, ] [[package]] @@ -910,7 +987,7 @@ wheels = [ [[package]] name = "jupyterlab" -version = "4.5.6" +version = "4.5.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-lru" }, @@ -927,9 +1004,9 @@ dependencies = [ { name = "tornado" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/d5/730628e03fff2e8a8e8ccdaedde1489ab1309f9a4fa2536248884e30b7c7/jupyterlab-4.5.6.tar.gz", hash = "sha256:642fe2cfe7f0f5922a8a558ba7a0d246c7bc133b708dfe43f7b3a826d163cf42", size = 23970670, upload-time = "2026-03-11T14:17:04.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/22/8440ec827762146e7cdecf04335bd348795899d29dc6ae82238707353a2c/jupyterlab-4.5.7.tar.gz", hash = "sha256:55a9822c4754da305f41e113452c68383e214dcf96de760146af89ce5d5117b0", size = 23992763, upload-time = "2026-04-29T16:43:51.328Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/1b/dad6fdcc658ed7af26fdf3841e7394072c9549a8b896c381ab49dd11e2d9/jupyterlab-4.5.6-py3-none-any.whl", hash = "sha256:d6b3dac883aa4d9993348e0f8e95b24624f75099aed64eab6a4351a9cdd1e580", size = 12447124, upload-time = "2026-03-11T14:17:00.229Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/537b8f7d80e799af19af35fb3ddfc970b951088a13c57dd9387dcfbb7f61/jupyterlab-4.5.7-py3-none-any.whl", hash = "sha256:fba4cb0e2c44a52859669d8c98b45de029d5e515f8407bf8534d2a8fc5f0964d", size = 12450123, upload-time = "2026-04-29T16:43:46.639Z" }, ] [[package]] @@ -1031,23 +1108,23 @@ wheels = [ [[package]] name = "matplotlib-inline" -version = "0.2.1" +version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, ] [[package]] name = "mistune" -version = "3.2.0" +version = "3.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/84/620cc3f7e3adf6f5067e10f4dbae71295d8f9e16d5d3f9ef97c40f2f592c/mistune-3.2.1.tar.gz", hash = "sha256:7c8e5501d38bac1582e067e46c8343f17d57ea1aaa735823f3aba1fd59c88a28", size = 98003, upload-time = "2026-05-03T14:33:22.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" }, ] [[package]] @@ -1067,7 +1144,7 @@ wheels = [ [[package]] name = "nbconvert" -version = "7.17.0" +version = "7.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, @@ -1085,9 +1162,9 @@ dependencies = [ { name = "pygments" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/47/81f886b699450d0569f7bc551df2b1673d18df7ff25cc0c21ca36ed8a5ff/nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78", size = 862855, upload-time = "2026-01-29T16:37:48.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/4b/8d5f796a792f8a25f6925a96032f098789f448571eb92011df1ae59e8ea8/nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518", size = 261510, upload-time = "2026-01-29T16:37:46.322Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, ] [[package]] @@ -1114,9 +1191,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "notebook" -version = "7.5.5" +version = "7.5.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jupyter-server" }, @@ -1125,9 +1211,9 @@ dependencies = [ { name = "notebook-shim" }, { name = "tornado" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/6d/41052c48d6f6349ca0a7c4d1f6a78464de135e6d18f5829ba2510e62184c/notebook-7.5.5.tar.gz", hash = "sha256:dc0bfab0f2372c8278c457423d3256c34154ac2cc76bf20e9925260c461013c3", size = 14169167, upload-time = "2026-03-11T16:32:51.922Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/c2/cf59bd2e6f2c8b976b52477e3e53bf6f97bc714ed046a51821afb428eaee/notebook-7.5.6.tar.gz", hash = "sha256:621174aade80108f0020b0f00738000b215f75fa3cd90771ad7aa0f24536a4e1", size = 14170814, upload-time = "2026-04-30T11:46:26.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/aa/cbd1deb9f07446241e88f8d5fecccd95b249bca0b4e5482214a4d1714c49/notebook-7.5.5-py3-none-any.whl", hash = "sha256:a7c14dbeefa6592e87f72290ca982e0c10f5bbf3786be2a600fda9da2764a2b8", size = 14578929, upload-time = "2026-03-11T16:32:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d6/1fd0646b9bbd9efbb0b8ae21b2325fbef515769a5621c03e31d8eb8da587/notebook-7.5.6-py3-none-any.whl", hash = "sha256:4dde3f8fb55fa8fb7946d58c6e869ce9baf46d00fc070664f62604569d0faca0", size = 14581730, upload-time = "2026-04-30T11:46:22.342Z" }, ] [[package]] @@ -1144,11 +1230,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.0" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] @@ -1162,11 +1248,11 @@ wheels = [ [[package]] name = "parso" -version = "0.8.6" +version = "0.8.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, ] [[package]] @@ -1183,88 +1269,88 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, - { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, - { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] [[package]] name = "platformdirs" -version = "4.9.4" +version = "4.9.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, ] [[package]] name = "playwright" -version = "1.58.0" +version = "1.60.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet" }, { name = "pyee" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" }, - { url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" }, - { url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" }, - { url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" }, - { url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" }, - { url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" }, - { url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" }, + { url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" }, + { url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" }, + { url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" }, + { url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" }, + { url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" }, ] [[package]] @@ -1276,13 +1362,29 @@ 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 = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + [[package]] name = "prometheus-client" -version = "0.24.1" +version = "0.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, ] [[package]] @@ -1327,32 +1429,32 @@ wheels = [ [[package]] name = "psycopg2-binary" -version = "2.9.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, - { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, - { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, - { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, - { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, - { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, - { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, - { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, - { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, - { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, - { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, - { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, - { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, - { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, - { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, - { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, - { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, +version = "2.9.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" }, + { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" }, + { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" }, + { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, + { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" }, + { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" }, + { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, ] [[package]] @@ -1384,7 +1486,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1392,9 +1494,9 @@ dependencies = [ { 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" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [package.optional-dependencies] @@ -1404,55 +1506,58 @@ email = [ [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.4" 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/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" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, ] [[package]] @@ -1481,11 +1586,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -1555,7 +1660,7 @@ fastapi = [ [[package]] name = "pytest-playwright" -version = "0.7.2" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "playwright" }, @@ -1563,9 +1668,9 @@ dependencies = [ { name = "pytest-base-url" }, { name = "python-slugify" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/6b/913e36aa421b35689ec95ed953ff7e8df3f2ee1c7b8ab2a3f1fd39d95faf/pytest_playwright-0.7.2.tar.gz", hash = "sha256:247b61123b28c7e8febb993a187a07e54f14a9aa04edc166f7a976d88f04c770", size = 16928, upload-time = "2025-11-24T03:43:22.53Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ef/172eb8e23c80491fc72f1401c72f9305663873649351306a38b18406b0c9/pytest_playwright-0.8.0.tar.gz", hash = "sha256:7888d4a2443160c82e0c506c437076679f86b36d1910427250d90fbf1843a981", size = 17132, upload-time = "2026-05-18T10:16:15.919Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/61/4d333d8354ea2bea2c2f01bad0a4aa3c1262de20e1241f78e73360e9b620/pytest_playwright-0.7.2-py3-none-any.whl", hash = "sha256:8084e015b2b3ecff483c2160f1c8219b38b66c0d4578b23c0f700d1b0240ea38", size = 16881, upload-time = "2025-11-24T03:43:24.423Z" }, + { url = "https://files.pythonhosted.org/packages/fe/71/1c545fac6a9054b52b3771238fb2dc6e8f1d0ccec116e1c7786ec191887c/pytest_playwright-0.8.0-py3-none-any.whl", hash = "sha256:856aae6efd4bc055f2ef229c647768760bcaad5cd3a5983c314ac260a974a933", size = 17143, upload-time = "2026-05-18T10:16:18.226Z" }, ] [[package]] @@ -1580,6 +1685,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-discovery" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1591,20 +1709,20 @@ wheels = [ [[package]] name = "python-json-logger" -version = "4.0.0" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573, upload-time = "2026-03-29T04:39:56.805Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" }, ] [[package]] name = "python-multipart" -version = "0.0.22" +version = "0.0.29" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" }, ] [[package]] @@ -1745,7 +1863,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1753,22 +1871,22 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] name = "resend" -version = "2.23.0" +version = "2.30.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/a3/20003e7d14604fef778bd30c69604df3560a657a95a5c29a9688610759b6/resend-2.23.0.tar.gz", hash = "sha256:df613827dcc40eb1c9de2e5ff600cd4081b89b206537dec8067af1a5016d23c7", size = 31416, upload-time = "2026-02-23T19:01:57.603Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/c7/b4c5bee252a2690cadb41063f280f4c09aac663e050e4425df545d28403d/resend-2.30.1.tar.gz", hash = "sha256:a529a019e83be285d855ecf135475dc6a6b9fa1c24f9b710686988e021a535ec", size = 43216, upload-time = "2026-05-13T15:39:18.172Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/35/64df775b8cd95e89798fd7b1b7fcafa975b6b09f559c10c0650e65b33580/resend-2.23.0-py2.py3-none-any.whl", hash = "sha256:eca6d28a1ffd36c1fc489fa83cb6b511f384792c9f07465f7c92d96c8b4d5636", size = 52599, upload-time = "2026-02-23T19:01:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/f2f5da31f7359dd39672a279f654e6eb86fb1a27aa9c90d0d9efbb962ebb/resend-2.30.1-py2.py3-none-any.whl", hash = "sha256:45da25b86940307e130f8a44d2bac889574362400843e9cd98b49b79a7b3db63", size = 68393, upload-time = "2026-05-13T15:39:17.114Z" }, ] [[package]] @@ -1872,27 +1990,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, - { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, - { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, - { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, - { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, - { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, - { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, - { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, - { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, - { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, - { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, ] [[package]] @@ -1933,41 +2051,41 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.48" +version = "2.0.49" 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/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" }, - { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" }, - { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" }, - { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" }, - { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" }, - { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" }, - { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" }, - { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" }, - { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" }, - { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, - { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, - { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, - { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, - { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, - { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, - { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, - { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" }, + { url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" }, + { url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" }, + { url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" }, + { url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, + { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, + { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, + { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, + { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, + { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, ] [[package]] @@ -1987,15 +2105,16 @@ wheels = [ [[package]] name = "sqlmodel" -version = "0.0.37" +version = "0.0.38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "sqlalchemy" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/26/1d2faa0fd5a765267f49751de533adac6b9ff9366c7c6e7692df4f32230f/sqlmodel-0.0.37.tar.gz", hash = "sha256:d2c19327175794faf50b1ee31cc966764f55b1dedefc046450bc5741a3d68352", size = 85527, upload-time = "2026-02-21T16:39:47.038Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/0d/26ec1329960ea9430131fe63f63a95ea4cb8971d49c891ff7e1f3255421c/sqlmodel-0.0.38.tar.gz", hash = "sha256:d583ec237b14103809f74e8630032bc40ab68cd6b754a610f0813c56911a547b", size = 86710, upload-time = "2026-04-02T21:03:55.571Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/e1/7c8d18e737433f3b5bbe27b56a9072a9fcb36342b48f1bef34b6da1d61f2/sqlmodel-0.0.37-py3-none-any.whl", hash = "sha256:2137a4045ef3fd66a917a7717ada959a1ceb3630d95e1f6aaab39dd2c0aef278", size = 27224, upload-time = "2026-02-21T16:39:47.781Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/10c60af0607ab6fa136264f7f39d205932218516226d38585324ffda705d/sqlmodel-0.0.38-py3-none-any.whl", hash = "sha256:84e3fa990a77395461ded72a6c73173438ce8449d5c1c4d97fbff1b1df692649", size = 27294, upload-time = "2026-04-02T21:03:56.406Z" }, ] [[package]] @@ -2014,14 +2133,14 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] [[package]] @@ -2078,35 +2197,36 @@ wheels = [ [[package]] name = "traitlets" -version = "5.14.3" +version = "5.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/22/40f55b26baeab80c2d7b3f1db0682f8954e4617fee7d90ce634022ef05c6/traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971", size = 163197, upload-time = "2026-05-06T08:05:58.016Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, + { url = "https://files.pythonhosted.org/packages/da/98/a9937a969d018a23badfea0b381f66783649d48e0ea6c41923265c3cbeb3/traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40", size = 85877, upload-time = "2026-05-06T08:05:55.853Z" }, ] [[package]] name = "ty" -version = "0.0.23" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/ba/d3c998ff4cf6b5d75b39356db55fe1b7caceecc522b9586174e6a5dee6f7/ty-0.0.23.tar.gz", hash = "sha256:5fb05db58f202af366f80ef70f806e48f5237807fe424ec787c9f289e3f3a4ef", size = 5341461, upload-time = "2026-03-13T12:34:23.125Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/21/aab32603dfdfacd4819e52fa8c6074e7bd578218a5142729452fc6a62db6/ty-0.0.23-py3-none-linux_armv6l.whl", hash = "sha256:e810eef1a5f1cfc0731a58af8d2f334906a96835829767aed00026f1334a8dd7", size = 10329096, upload-time = "2026-03-13T12:34:09.432Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a9/dd3287a82dce3df546ec560296208d4905dcf06346b6e18c2f3c63523bd1/ty-0.0.23-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e43d36bd89a151ddcad01acaeff7dcc507cb73ff164c1878d2d11549d39a061c", size = 10156631, upload-time = "2026-03-13T12:34:53.122Z" }, - { url = "https://files.pythonhosted.org/packages/0f/01/3f25909b02fac29bb0a62b2251f8d62e65d697781ffa4cf6b47a4c075c85/ty-0.0.23-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bd6a340969577b4645f231572c4e46012acba2d10d4c0c6570fe1ab74e76ae00", size = 9653211, upload-time = "2026-03-13T12:34:15.049Z" }, - { url = "https://files.pythonhosted.org/packages/d5/60/bfc0479572a6f4b90501c869635faf8d84c8c68ffc5dd87d04f049affabc/ty-0.0.23-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:341441783e626eeb7b1ec2160432956aed5734932ab2d1c26f94d0c98b229937", size = 10156143, upload-time = "2026-03-13T12:34:34.468Z" }, - { url = "https://files.pythonhosted.org/packages/3a/81/8a93e923535a340f54bea20ff196f6b2787782b2f2f399bd191c4bc132d6/ty-0.0.23-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ce1dc66c26d4167e2c78d12fa870ef5a7ec9cc344d2baaa6243297cfa88bd52", size = 10136632, upload-time = "2026-03-13T12:34:28.832Z" }, - { url = "https://files.pythonhosted.org/packages/da/cb/2ac81c850c58acc9f976814404d28389c9c1c939676e32287b9cff61381e/ty-0.0.23-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bae1e7a294bf8528836f7617dc5c360ea2dddb63789fc9471ae6753534adca05", size = 10655025, upload-time = "2026-03-13T12:34:37.105Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9b/bac771774c198c318ae699fc013d8cd99ed9caf993f661fba11238759244/ty-0.0.23-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b162768764d9dc177c83fb497a51532bb67cbebe57b8fa0f2668436bf53f3c", size = 11230107, upload-time = "2026-03-13T12:34:20.751Z" }, - { url = "https://files.pythonhosted.org/packages/14/09/7644fb0e297265e18243f878aca343593323b9bb19ed5278dcbc63781be0/ty-0.0.23-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d28384e48ca03b34e4e2beee0e230c39bbfb68994bb44927fec61ef3642900da", size = 10934177, upload-time = "2026-03-13T12:34:17.904Z" }, - { url = "https://files.pythonhosted.org/packages/18/14/69a25a0cad493fb6a947302471b579a03516a3b00e7bece77fdc6b4afb9b/ty-0.0.23-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:559d9a299df793cb7a7902caed5eda8a720ff69164c31c979673e928f02251ee", size = 10752487, upload-time = "2026-03-13T12:34:31.785Z" }, - { url = "https://files.pythonhosted.org/packages/9d/2a/42fc3cbccf95af0a62308ebed67e084798ab7a85ef073c9986ef18032743/ty-0.0.23-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:32a7b8a14a98e1d20a9d8d2af23637ed7efdb297ac1fa2450b8e465d05b94482", size = 10133007, upload-time = "2026-03-13T12:34:42.838Z" }, - { url = "https://files.pythonhosted.org/packages/e1/69/307833f1b52fa3670e0a1d496e43ef7df556ecde838192d3fcb9b35e360d/ty-0.0.23-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6f803b9b9cca87af793467973b9abdd4b83e6b96d9b5e749d662cff7ead70b6d", size = 10169698, upload-time = "2026-03-13T12:34:12.351Z" }, - { url = "https://files.pythonhosted.org/packages/89/ae/5dd379ec22d0b1cba410d7af31c366fcedff191d5b867145913a64889f66/ty-0.0.23-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4a0bf086ec8e2197b7ea7ebfcf4be36cb6a52b235f8be61647ef1b2d99d6ffd3", size = 10346080, upload-time = "2026-03-13T12:34:40.012Z" }, - { url = "https://files.pythonhosted.org/packages/98/c7/dfc83203d37998620bba9c4873a080c8850a784a8a46f56f8163c5b4e320/ty-0.0.23-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:252539c3fcd7aeb9b8d5c14e2040682c3e1d7ff640906d63fd2c4ce35865a4ba", size = 10848162, upload-time = "2026-03-13T12:34:45.421Z" }, - { url = "https://files.pythonhosted.org/packages/89/08/05481511cfbcc1fd834b6c67aaae090cb609a079189ddf2032139ccfc490/ty-0.0.23-py3-none-win32.whl", hash = "sha256:51b591d19eef23bbc3807aef77d38fa1f003c354e1da908aa80ea2dca0993f77", size = 9748283, upload-time = "2026-03-13T12:34:50.607Z" }, - { url = "https://files.pythonhosted.org/packages/31/2e/eaed4ff5c85e857a02415084c394e02c30476b65e158eec1938fdaa9a205/ty-0.0.23-py3-none-win_amd64.whl", hash = "sha256:1e137e955f05c501cfbb81dd2190c8fb7d01ec037c7e287024129c722a83c9ad", size = 10698355, upload-time = "2026-03-13T12:34:26.134Z" }, - { url = "https://files.pythonhosted.org/packages/91/29/b32cb7b4c7d56b9ed50117f8ad6e45834aec293e4cb14749daab4e9236d5/ty-0.0.23-py3-none-win_arm64.whl", hash = "sha256:a0399bd13fd2cd6683fd0a2d59b9355155d46546d8203e152c556ddbdeb20842", size = 10155890, upload-time = "2026-03-13T12:34:48.082Z" }, +version = "0.0.38" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/3b/45be6b37d5060d6917bf7f1f234c00d360fc5f8b7486f8a96af640e25661/ty-0.0.38.tar.gz", hash = "sha256:fbc8d47f7630457669ab41e333dc093897fdb7ead1ffc94dcf8f30b5d39aa56d", size = 5681218, upload-time = "2026-05-20T00:15:32.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/43/ea9b4e57d6a266670dbe34858e92f6093ca054ad1b48f1c82580a72340fb/ty-0.0.38-py3-none-linux_armv6l.whl", hash = "sha256:3501dcf44ca03f813f9cb4fabfdf601adc0ac1337c411405b470530679e37a45", size = 11289326, upload-time = "2026-05-20T00:14:52.371Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ff/24e2f623a1c6b5f5ccf8bf82fccd937033c6a7dba57a4028c7f41270fa4a/ty-0.0.38-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b34b4094b76252c3e8c90762cdd5e8a9f1101534484745ff4b480f71eb38ac2e", size = 11063047, upload-time = "2026-05-20T00:14:42.832Z" }, + { url = "https://files.pythonhosted.org/packages/e9/41/4f0d910f0acbd20b358eda80a5cd6a8361d27ff5b8e87ab559d3f69f125e/ty-0.0.38-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c518ad33a877677365baab2e21d82cf59ffee789203a15a143f5179ee5a1d3f8", size = 10494436, upload-time = "2026-05-20T00:15:24.425Z" }, + { url = "https://files.pythonhosted.org/packages/69/d8/da06833422082aa98b169a391f9197e2d73865e96c90b6979ac886b890a2/ty-0.0.38-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9238494722303eccddc6a27eb647948b694eecd6b974910d13b9e6cd46bbeb6a", size = 11000992, upload-time = "2026-05-20T00:14:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/16/f7/e1172197fb827e6410ca3eb0dc68ef2789f3c70683696f2a0ce5c90764fd/ty-0.0.38-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d91d7336c5d51bf822ac0df512f300584ca4dcca041fc6a6d7df03a8ddbb31", size = 11058583, upload-time = "2026-05-20T00:15:11.314Z" }, + { url = "https://files.pythonhosted.org/packages/5b/61/7fbaf0c05981e006a8804287819c574dff90a6bf8e96efad7226be0700aa/ty-0.0.38-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65165879814993450710b9349791e4898c65e36b1e14eec554884c06a2f20ff1", size = 11531036, upload-time = "2026-05-20T00:15:14.62Z" }, + { url = "https://files.pythonhosted.org/packages/49/e3/47c0c64e401d50f925df3e52479d4e7626754b2a9e38201d142fdacd6252/ty-0.0.38-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d61868b8d1c4033bf8088191de953fed245c2f9e1bb9d2d53e5699170b0924c", size = 12129991, upload-time = "2026-05-20T00:14:39.475Z" }, + { url = "https://files.pythonhosted.org/packages/90/99/2f452d02901bcd7f1b109cf5b848727ce37f372c3406143aa52d1305d40e/ty-0.0.38-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8f9a9175548c98dbff7707865738c07c2b1f8e07a09b8c68101baebb5dac59a4", size = 11756167, upload-time = "2026-05-20T00:15:27.526Z" }, + { url = "https://files.pythonhosted.org/packages/dd/0c/c7e14d111c813e1a20b82e944f1c997c4631a2bb710eaa64fb6b26835e13/ty-0.0.38-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:375d3a964c6b4aea2e9237fdb5eb9ed03dc43088986a94209a28a4ea3b62001c", size = 11637099, upload-time = "2026-05-20T00:15:21.261Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/ab02659dd1ed62898db7db4d37f9937c80854dd45e95093fa0fe10328d82/ty-0.0.38-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:cdfd547782c45267aa0b52abad31bd406bf4768c264532ef9e2360cd3c6ce048", size = 11813583, upload-time = "2026-05-20T00:14:45.875Z" }, + { url = "https://files.pythonhosted.org/packages/7e/57/bd1b5ebf4e71a4295484afac0202df1740b0807762b86744b1bef4534984/ty-0.0.38-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:858bc675b75626470abe4e6c3b3934b853642b04f2ac4d7139fcefea3b48b213", size = 10975405, upload-time = "2026-05-20T00:15:30.354Z" }, + { url = "https://files.pythonhosted.org/packages/e7/55/0305c78711bbd23922cf291996a08ef9544f4179da98e9a75c14e608f379/ty-0.0.38-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:54be4f00432870da42cd74fe145a3362fd248e22d032c74bd807cb45bf068f94", size = 11097551, upload-time = "2026-05-20T00:14:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4f/7effe7f9a6ac9719eb7234172c01739c5f888bb47f9acc2ea8da1f4afed3/ty-0.0.38-py3-none-musllinux_1_2_i686.whl", hash = "sha256:494af66a76a86dbf16a3003d3b63b03484aa4c7489dfe11f3ee5413b98b22d60", size = 11214391, upload-time = "2026-05-20T00:15:18.094Z" }, + { url = "https://files.pythonhosted.org/packages/75/cd/d9fdfec3a74a6ad0209fa5e7113ae29d4f457d0651cfbb813b4c6563e0d4/ty-0.0.38-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3d92527c4be78a5ce6d32e8bb0aa2a6988d4076eddf1294e56fdaf06d1a98e7e", size = 11730871, upload-time = "2026-05-20T00:14:49.219Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4a/beefade12d109b4f7793d61b04b4478b1ad4d1465a719e7ff55b2d42461a/ty-0.0.38-py3-none-win32.whl", hash = "sha256:36fc5dd5dc09207ff3004b1560a79a3fb8d12456daeec914a7b802a918da654c", size = 10548583, upload-time = "2026-05-20T00:15:07.892Z" }, + { url = "https://files.pythonhosted.org/packages/15/64/941b205e2e46cc2297c245c64aa7691410b7454fa4d07a6cb3cf59487833/ty-0.0.38-py3-none-win_amd64.whl", hash = "sha256:eef0a8956ba14514076b1a963d13eb32986d9ebad7f0527b3cc01cb68bf35147", size = 11650542, upload-time = "2026-05-20T00:15:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/59/02/c1c4f9ec4b94d95190636fa13f79c32f65165fbe3a0503882d4df164d2ac/ty-0.0.38-py3-none-win_arm64.whl", hash = "sha256:79abfc8658a026c30b1c955613437dab3ef4b12feca56a3e6df50903cc39e07f", size = 11010307, upload-time = "2026-05-20T00:15:04.567Z" }, ] [[package]] @@ -2132,11 +2252,11 @@ wheels = [ [[package]] name = "tzdata" -version = "2025.3" +version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] [[package]] @@ -2150,33 +2270,48 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] name = "uvicorn" -version = "0.41.0" +version = "0.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, + { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, ] [[package]] name = "wcwidth" -version = "0.6.0" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, ] [[package]]