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 ''
+ 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')[\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''
- assert re.search(owner_edit_button_pattern, response.text) is None, "Edit button found for Owner role"
+ owner_edit_button_pattern = (
+ r''
+ )
+ 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''
- assert re.search(admin_edit_button_pattern, response.text) is None, "Edit button found for Administrator role"
+ admin_edit_button_pattern = (
+ r''
+ )
+ 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''
- assert re.search(member_edit_button_pattern, response.text) is None, "Edit button found for Member role"
+ member_edit_button_pattern = (
+ r''
+ )
+ 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''
- 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''
- 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''
- 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*)',
- 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]]