auth email/password

This commit is contained in:
JSC
2025-06-28 18:30:30 +02:00
parent 8e2dbd8723
commit ceafed9108
25 changed files with 1694 additions and 314 deletions

19
.env.example Normal file
View File

@@ -0,0 +1,19 @@
# Flask Configuration
SECRET_KEY=your_flask_secret_key_here
# JWT Configuration
JWT_SECRET_KEY=your_jwt_secret_key_here
# Database Configuration
DATABASE_URL=sqlite:///soundboard.db
# OAuth Providers Configuration
# Configure the providers you want to support by setting their client credentials
# Google OAuth
GOOGLE_CLIENT_ID=your_google_client_id_here
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
# GitHub OAuth
GITHUB_CLIENT_ID=your_github_client_id_here
GITHUB_CLIENT_SECRET=your_github_client_secret_here

7
.gitignore vendored
View File

@@ -5,6 +5,11 @@ build/
dist/ dist/
wheels/ wheels/
*.egg-info *.egg-info
.pytest_cache/
instance/
# Virtual environments # Virtual environments
.venv .venv/
# Env
.env

View File

@@ -1,19 +1,46 @@
import os import os
from datetime import timedelta
from flask import Flask from flask import Flask
from flask_jwt_extended import JWTManager
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.database import init_db
# Global auth service instance
auth_service = AuthService()
def create_app(): def create_app():
"""Create and configure the Flask application.""" """Create and configure the Flask application."""
app = Flask(__name__) app = Flask(__name__)
# Configure session # Configure Flask secret key (required for sessions used by OAuth)
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-key") app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-key")
# Initialize authentication service # Configure SQLAlchemy database
auth_service = AuthService(app) database_url = os.environ.get("DATABASE_URL", "sqlite:///soundboard.db")
app.config["SQLALCHEMY_DATABASE_URI"] = database_url
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
# Configure Flask-JWT-Extended
app.config["JWT_SECRET_KEY"] = os.environ.get("JWT_SECRET_KEY", "jwt-secret-key")
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(minutes=15)
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=7)
app.config["JWT_TOKEN_LOCATION"] = ["cookies"]
app.config["JWT_COOKIE_SECURE"] = False # Set to True in production
app.config["JWT_COOKIE_CSRF_PROTECT"] = False
app.config["JWT_ACCESS_COOKIE_PATH"] = "/api/"
app.config["JWT_REFRESH_COOKIE_PATH"] = "/api/auth/refresh"
# Initialize JWT manager
jwt = JWTManager(app)
# Initialize database
init_db(app)
# Initialize authentication service with app
auth_service.init_app(app)
# Register blueprints # Register blueprints
from app.routes import main, auth from app.routes import main, auth

18
app/database.py Normal file
View File

@@ -0,0 +1,18 @@
"""Database configuration and initialization."""
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
db = SQLAlchemy()
migrate = Migrate()
def init_db(app):
"""Initialize database with Flask app."""
db.init_app(app)
migrate.init_app(app, db)
# Import models here to ensure they are registered with SQLAlchemy
from app.models import user, user_oauth # noqa: F401
return db

6
app/models/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Database models."""
from .user import User
from .user_oauth import UserOAuth
__all__ = ["User", "UserOAuth"]

240
app/models/user.py Normal file
View File

@@ -0,0 +1,240 @@
"""User model for authentication."""
import secrets
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from werkzeug.security import check_password_hash, generate_password_hash
from sqlalchemy import String, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import db
if TYPE_CHECKING:
from app.models.user_oauth import UserOAuth
class User(db.Model):
"""User model for storing user information."""
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
# Primary user information (can be updated from any connected provider)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
picture: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Password authentication (optional - users can use OAuth instead)
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
# Role-based access control
role: Mapped[str] = mapped_column(String(50), nullable=False, default="user")
# User status
is_active: Mapped[bool] = mapped_column(nullable=False, default=True)
# API token for programmatic access
api_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
api_token_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
# Relationships
oauth_providers: Mapped[list["UserOAuth"]] = relationship(
"UserOAuth", back_populates="user", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
"""String representation of User."""
provider_count = len(self.oauth_providers)
return f"<User {self.email} ({provider_count} providers)>"
def to_dict(self) -> dict:
"""Convert user to dictionary."""
return {
"id": str(self.id),
"email": self.email,
"name": self.name,
"picture": self.picture,
"role": self.role,
"is_active": self.is_active,
"api_token": self.api_token,
"api_token_expires_at": self.api_token_expires_at.isoformat() if self.api_token_expires_at else None,
"providers": [provider.provider for provider in self.oauth_providers],
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
def get_provider(self, provider_name: str) -> Optional["UserOAuth"]:
"""Get specific OAuth provider for this user."""
for provider in self.oauth_providers:
if provider.provider == provider_name:
return provider
return None
def has_provider(self, provider_name: str) -> bool:
"""Check if user has specific OAuth provider connected."""
return self.get_provider(provider_name) is not None
def update_from_provider(self, provider_data: dict) -> None:
"""Update user info from provider data (email, name, picture)."""
self.email = provider_data.get("email", self.email)
self.name = provider_data.get("name", self.name)
self.picture = provider_data.get("picture", self.picture)
self.updated_at = datetime.utcnow()
db.session.commit()
def set_password(self, password: str) -> None:
"""Hash and set user password."""
self.password_hash = generate_password_hash(password)
self.updated_at = datetime.utcnow()
def check_password(self, password: str) -> bool:
"""Check if provided password matches user's password."""
if not self.password_hash:
return False
return check_password_hash(self.password_hash, password)
def has_password(self) -> bool:
"""Check if user has a password set."""
return self.password_hash is not None
def generate_api_token(self) -> str:
"""Generate a new API token for the user."""
self.api_token = secrets.token_urlsafe(32)
self.api_token_expires_at = None # No expiration by default
self.updated_at = datetime.utcnow()
return self.api_token
def is_api_token_valid(self) -> bool:
"""Check if the user's API token is valid (exists and not expired)."""
if not self.api_token:
return False
if self.api_token_expires_at is None:
return True # No expiration
return datetime.utcnow() < self.api_token_expires_at
def revoke_api_token(self) -> None:
"""Revoke the user's API token."""
self.api_token = None
self.api_token_expires_at = None
self.updated_at = datetime.utcnow()
def activate(self) -> None:
"""Activate the user account."""
self.is_active = True
self.updated_at = datetime.utcnow()
def deactivate(self) -> None:
"""Deactivate the user account."""
self.is_active = False
self.updated_at = datetime.utcnow()
@classmethod
def find_by_email(cls, email: str) -> Optional["User"]:
"""Find user by email address."""
return cls.query.filter_by(email=email).first()
@classmethod
def find_by_api_token(cls, api_token: str) -> Optional["User"]:
"""Find user by API token if token is valid."""
user = cls.query.filter_by(api_token=api_token).first()
if user and user.is_api_token_valid():
return user
return None
@classmethod
def find_or_create_from_oauth(
cls, provider: str, provider_id: str, email: str, name: str, picture: Optional[str] = None
) -> tuple["User", "UserOAuth"]:
"""Find existing user or create new one from OAuth data."""
from app.models.user_oauth import UserOAuth
# First, try to find existing OAuth provider
oauth_provider = UserOAuth.find_by_provider_and_id(provider, provider_id)
if oauth_provider:
# Update existing provider and user info
user = oauth_provider.user
oauth_provider.email = email
oauth_provider.name = name
oauth_provider.picture = picture
oauth_provider.updated_at = datetime.utcnow()
# Update user info with latest data
user.update_from_provider({"email": email, "name": name, "picture": picture})
else:
# Try to find user by email to link the new provider
user = cls.find_by_email(email)
if not user:
# Check if this is the first user (admin)
user_count = cls.query.count()
role = "admin" if user_count == 0 else "user"
# Create new user
user = cls(
email=email,
name=name,
picture=picture,
role=role,
)
user.generate_api_token() # Generate API token on creation
db.session.add(user)
db.session.flush() # Flush to get user.id
# Create new OAuth provider
oauth_provider = UserOAuth.create_or_update(
user_id=user.id,
provider=provider,
provider_id=provider_id,
email=email,
name=name,
picture=picture,
)
db.session.commit()
return user, oauth_provider
@classmethod
def create_with_password(cls, email: str, password: str, name: str) -> "User":
"""Create new user with email and password."""
# Check if user already exists
existing_user = cls.find_by_email(email)
if existing_user:
raise ValueError("User with this email already exists")
# Check if this is the first user (admin)
user_count = cls.query.count()
role = "admin" if user_count == 0 else "user"
# Create new user
user = cls(
email=email,
name=name,
role=role,
)
user.set_password(password)
user.generate_api_token() # Generate API token on creation
db.session.add(user)
db.session.commit()
return user
@classmethod
def authenticate_with_password(cls, email: str, password: str) -> Optional["User"]:
"""Authenticate user with email and password."""
user = cls.find_by_email(email)
if user and user.check_password(password) and user.is_active:
return user
return None

105
app/models/user_oauth.py Normal file
View File

@@ -0,0 +1,105 @@
"""User OAuth model for storing user's connected providers."""
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, DateTime, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import db
if TYPE_CHECKING:
from app.models.user import User
class UserOAuth(db.Model):
"""Model for storing user's connected OAuth providers."""
__tablename__ = "user_oauth"
id: Mapped[int] = mapped_column(primary_key=True)
# User relationship
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
# OAuth provider information
provider: Mapped[str] = mapped_column(String(50), nullable=False)
provider_id: Mapped[str] = mapped_column(String(255), nullable=False)
# Provider-specific user information
email: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
picture: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
# Unique constraint on provider + provider_id combination
__table_args__ = (
db.UniqueConstraint("provider", "provider_id", name="unique_provider_user"),
)
# Relationships
user: Mapped["User"] = relationship("User", back_populates="oauth_providers")
def __repr__(self) -> str:
"""String representation of UserOAuth."""
return f"<UserOAuth {self.email} ({self.provider})>"
def to_dict(self) -> dict:
"""Convert oauth provider to dictionary."""
return {
"id": self.id,
"provider": self.provider,
"provider_id": self.provider_id,
"email": self.email,
"name": self.name,
"picture": self.picture,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@classmethod
def find_by_provider_and_id(cls, provider: str, provider_id: str) -> Optional["UserOAuth"]:
"""Find OAuth provider by provider name and provider ID."""
return cls.query.filter_by(provider=provider, provider_id=provider_id).first()
@classmethod
def create_or_update(
cls,
user_id: int,
provider: str,
provider_id: str,
email: str,
name: str,
picture: Optional[str] = None
) -> "UserOAuth":
"""Create new OAuth provider or update existing one."""
oauth_provider = cls.find_by_provider_and_id(provider, provider_id)
if oauth_provider:
# Update existing provider
oauth_provider.user_id = user_id
oauth_provider.email = email
oauth_provider.name = name
oauth_provider.picture = picture
oauth_provider.updated_at = datetime.utcnow()
else:
# Create new provider
oauth_provider = cls(
user_id=user_id,
provider=provider,
provider_id=provider_id,
email=email,
name=name,
picture=picture,
)
db.session.add(oauth_provider)
db.session.commit()
return oauth_provider

View File

@@ -1,31 +1,54 @@
"""Authentication routes.""" """Authentication routes."""
from flask import Blueprint, url_for from flask import Blueprint, jsonify, url_for
from flask_jwt_extended import create_access_token, get_jwt_identity, jwt_required
from app.services.auth_service import AuthService from app import auth_service
from app.services.decorators import get_current_user
bp = Blueprint("auth", __name__) bp = Blueprint("auth", __name__)
auth_service = AuthService()
@bp.route("/login") @bp.route("/login/<provider>")
def login() -> dict[str, str]: def login_oauth(provider):
"""Initiate Google OAuth login.""" """Initiate OAuth login for specified provider."""
redirect_uri = url_for("auth.callback", _external=True) redirect_uri = url_for("auth.callback", provider=provider, _external=True)
login_url = auth_service.get_login_url(redirect_uri) return auth_service.redirect_to_login(provider, redirect_uri)
return {"login_url": login_url}
@bp.route("/callback") @bp.route("/callback/<provider>")
def callback(): def callback(provider):
"""Handle OAuth callback from Google.""" """Handle OAuth callback from specified provider."""
try: try:
user_data, response = auth_service.handle_callback() return auth_service.handle_callback(provider)
return response
except Exception as e: except Exception as e:
return {"error": str(e)}, 400 return {"error": str(e)}, 400
@bp.route("/providers")
def providers():
"""Get list of available OAuth providers."""
return {"providers": auth_service.get_available_providers()}
@bp.route("/login", methods=["POST"])
def login():
"""Login user with email and password."""
from flask import request
data = request.get_json()
if not data:
return {"error": "No data provided"}, 400
email = data.get("email")
password = data.get("password")
if not email or not password:
return {"error": "Email and password are required"}, 400
return auth_service.login_with_password(email, password)
@bp.route("/logout") @bp.route("/logout")
def logout(): def logout():
"""Logout current user.""" """Logout current user."""
@@ -33,20 +56,182 @@ def logout():
@bp.route("/me") @bp.route("/me")
def me() -> dict[str, str] | tuple[dict[str, str], int]: @jwt_required()
def me():
"""Get current user information.""" """Get current user information."""
user = auth_service.get_current_user() user = get_current_user()
if not user:
return {"error": "Not authenticated"}, 401
return {"user": user} return {"user": user}
@bp.route("/refresh") @bp.route("/refresh", methods=["POST"])
@jwt_required(refresh=True)
def refresh(): def refresh():
"""Refresh access token using refresh token.""" """Refresh access token using refresh token."""
response = auth_service.refresh_tokens() current_user_id = get_jwt_identity()
if not response:
return {"error": "Invalid or expired refresh token"}, 401 # Create new access token
new_access_token = create_access_token(identity=current_user_id)
response = jsonify({"message": "Token refreshed"})
# Set new access token cookie
from flask_jwt_extended import set_access_cookies
set_access_cookies(response, new_access_token)
return response
@bp.route("/link/<provider>")
@jwt_required()
def link_provider(provider):
"""Link a new OAuth provider to current user account."""
redirect_uri = url_for("auth.link_callback", provider=provider, _external=True)
return auth_service.redirect_to_login(provider, redirect_uri)
@bp.route("/link/callback/<provider>")
@jwt_required()
def link_callback(provider):
"""Handle OAuth callback for linking new provider."""
try:
current_user_id = get_jwt_identity()
if not current_user_id:
return {"error": "User not authenticated"}, 401
# Get current user from database
from app.models.user import User
user = User.query.get(current_user_id)
if not user:
return {"error": "User not found"}, 404
# Process OAuth callback but link to existing user
from app.services.oauth_providers.registry import OAuthProviderRegistry
from authlib.integrations.flask_client import OAuth
oauth = OAuth()
registry = OAuthProviderRegistry(oauth)
oauth_provider = registry.get_provider(provider)
if not oauth_provider:
return {"error": f"OAuth provider '{provider}' not configured"}, 400
token = oauth_provider.exchange_code_for_token(None, None)
raw_user_info = oauth_provider.get_user_info(token)
provider_data = oauth_provider.normalize_user_data(raw_user_info)
if not provider_data.get("id"):
return {"error": "Failed to get user information from provider"}, 400
# Check if this provider is already linked to another user
from app.models.user_oauth import UserOAuth
existing_provider = UserOAuth.find_by_provider_and_id(
provider, provider_data["id"]
)
if existing_provider and existing_provider.user_id != user.id:
return {"error": "This provider account is already linked to another user"}, 409
# Link the provider to current user
UserOAuth.create_or_update(
user_id=user.id,
provider=provider,
provider_id=provider_data["id"],
email=provider_data["email"],
name=provider_data["name"],
picture=provider_data.get("picture")
)
return {"message": f"{provider.title()} account linked successfully"}
except Exception as e:
return {"error": str(e)}, 400
@bp.route("/unlink/<provider>", methods=["DELETE"])
@jwt_required()
def unlink_provider(provider):
"""Unlink an OAuth provider from current user account."""
try:
current_user_id = get_jwt_identity()
if not current_user_id:
return {"error": "User not authenticated"}, 401
from app.models.user import User
from app.models.user_oauth import UserOAuth
from app.database import db
user = User.query.get(current_user_id)
if not user:
return {"error": "User not found"}, 404
# Check if user has more than one provider (prevent locking out)
if len(user.oauth_providers) <= 1:
return {"error": "Cannot unlink last authentication provider"}, 400
# Find and remove the provider
oauth_provider = user.get_provider(provider)
if not oauth_provider:
return {"error": f"Provider '{provider}' not linked to this account"}, 404
db.session.delete(oauth_provider)
db.session.commit()
return {"message": f"{provider.title()} account unlinked successfully"}
except Exception as e:
return {"error": str(e)}, 400
@bp.route("/register", methods=["POST"])
def register():
"""Register new user with email and password."""
from flask import request
data = request.get_json()
if not data:
return {"error": "No data provided"}, 400
email = data.get("email")
password = data.get("password")
name = data.get("name")
if not email or not password or not name:
return {"error": "Email, password, and name are required"}, 400
# Basic email validation
if "@" not in email or "." not in email:
return {"error": "Invalid email format"}, 400
# Basic password validation
if len(password) < 6:
return {"error": "Password must be at least 6 characters long"}, 400
return auth_service.register_with_password(email, password, name)
@bp.route("/regenerate-api-token", methods=["POST"])
@jwt_required()
def regenerate_api_token():
"""Regenerate API token for current user."""
current_user_id = get_jwt_identity()
if not current_user_id:
return {"error": "User not authenticated"}, 401
from app.models.user import User
from app.database import db
user = User.query.get(current_user_id)
if not user:
return {"error": "User not found"}, 404
# Generate new API token
new_token = user.generate_api_token()
db.session.commit()
return {
"message": "API token regenerated successfully",
"api_token": new_token,
"expires_at": user.api_token_expires_at.isoformat() if user.api_token_expires_at else None
}
return response

View File

@@ -2,7 +2,7 @@
from flask import Blueprint from flask import Blueprint
from app.services.decorators import get_current_user, require_auth from app.services.decorators import get_current_user, require_auth, require_admin, require_auth_or_api_token, get_user_from_api_token
from app.services.greeting_service import GreetingService from app.services.greeting_service import GreetingService
bp = Blueprint("main", __name__) bp = Blueprint("main", __name__)
@@ -24,7 +24,7 @@ def hello(name: str | None = None) -> dict[str, str]:
@bp.route("/protected") @bp.route("/protected")
@require_auth @require_auth
def protected() -> dict[str, str]: def protected() -> dict[str, str]:
"""Protected endpoint that requires authentication.""" """Protected endpoint that requires JWT authentication."""
user = get_current_user() user = get_current_user()
return { return {
"message": f"Hello {user['name']}, this is a protected endpoint!", "message": f"Hello {user['name']}, this is a protected endpoint!",
@@ -32,6 +32,33 @@ def protected() -> dict[str, str]:
} }
@bp.route("/api-protected")
@require_auth_or_api_token
def api_protected() -> dict[str, str]:
"""Protected endpoint that accepts JWT or API token authentication."""
# Try to get user from JWT first, then API token
user = get_current_user()
if not user:
user = get_user_from_api_token()
return {
"message": f"Hello {user['name']}, you accessed this via {user['provider']}!",
"user": user
}
@bp.route("/admin")
@require_admin
def admin_only() -> dict[str, str]:
"""Admin-only endpoint to demonstrate role-based access."""
user = get_current_user()
return {
"message": f"Hello admin {user['name']}, you have admin access!",
"user": user,
"admin_info": "This endpoint is only accessible to admin users"
}
@bp.route("/health") @bp.route("/health")
def health() -> dict[str, str]: def health() -> dict[str, str]:
"""Health check endpoint.""" """Health check endpoint."""

View File

@@ -1,21 +1,29 @@
"""Authentication service for Google OAuth.""" """Authentication service for multiple OAuth providers."""
import os
from typing import Any from typing import Any
from authlib.integrations.flask_client import OAuth from authlib.integrations.flask_client import OAuth
from flask import Flask, make_response, request from flask import Flask, jsonify
from flask_jwt_extended import (
get_jwt_identity,
jwt_required,
set_access_cookies,
set_refresh_cookies,
unset_jwt_cookies,
)
from app.models.user import User
from app.services.oauth_providers.registry import OAuthProviderRegistry
from app.services.token_service import TokenService from app.services.token_service import TokenService
class AuthService: class AuthService:
"""Service for handling Google OAuth authentication.""" """Service for handling multiple OAuth providers authentication."""
def __init__(self, app: Flask | None = None) -> None: def __init__(self, app: Flask | None = None) -> None:
"""Initialize the authentication service.""" """Initialize the authentication service."""
self.oauth = OAuth() self.oauth = OAuth()
self.google = None self.provider_registry = None
self.token_service = TokenService() self.token_service = TokenService()
if app: if app:
self.init_app(app) self.init_app(app)
@@ -24,120 +32,219 @@ class AuthService:
"""Initialize the service with Flask app.""" """Initialize the service with Flask app."""
self.oauth.init_app(app) self.oauth.init_app(app)
# Configure Google OAuth # Initialize provider registry
self.google = self.oauth.register( self.provider_registry = OAuthProviderRegistry(self.oauth)
name="google",
client_id=os.getenv("GOOGLE_CLIENT_ID"),
client_secret=os.getenv("GOOGLE_CLIENT_SECRET"),
server_metadata_url="https://accounts.google.com/.well-known/openid_configuration",
client_kwargs={"scope": "openid email profile"},
)
def get_login_url(self, redirect_uri: str) -> str: def redirect_to_login(self, provider_name: str, redirect_uri: str):
"""Generate Google OAuth login URL.""" """Redirect to OAuth provider login."""
if not self.google: provider = self.provider_registry.get_provider(provider_name)
msg = "Google OAuth not configured" if not provider:
msg = f"OAuth provider '{provider_name}' not configured"
raise RuntimeError(msg) raise RuntimeError(msg)
return self.google.authorize_redirect(redirect_uri).location
def handle_callback(self) -> tuple[dict[str, Any], Any]: client = provider.get_client()
return client.authorize_redirect(redirect_uri)
def get_login_url(self, provider_name: str, redirect_uri: str) -> str:
"""Generate OAuth provider login URL (for testing or manual use)."""
provider = self.provider_registry.get_provider(provider_name)
if not provider:
msg = f"OAuth provider '{provider_name}' not configured"
raise RuntimeError(msg)
return provider.get_authorization_url(redirect_uri)
def handle_callback(self, provider_name: str) -> Any:
"""Handle OAuth callback and exchange code for token.""" """Handle OAuth callback and exchange code for token."""
if not self.google: provider = self.provider_registry.get_provider(provider_name)
msg = "Google OAuth not configured" if not provider:
msg = f"OAuth provider '{provider_name}' not configured"
raise RuntimeError(msg) raise RuntimeError(msg)
token = self.google.authorize_access_token() token = provider.exchange_code_for_token(None, None)
user_info = token.get("userinfo") raw_user_info = provider.get_user_info(token)
user_data = provider.normalize_user_data(raw_user_info)
if user_info: if user_data and user_data.get("id"):
user_data = { # Find or create user in database
"id": user_info["sub"], user, oauth_provider = User.find_or_create_from_oauth(
"email": user_info["email"], provider=provider_name,
"name": user_info["name"], provider_id=user_data["id"],
"picture": user_info.get("picture"), email=user_data["email"],
name=user_data["name"],
picture=user_data.get("picture"),
)
# Check if user account is active
if not user.is_active:
response = jsonify({"error": "Account is disabled"})
response.status_code = 401
return response
# Prepare user data for JWT token
jwt_user_data = {
"id": str(user.id),
"email": user.email,
"name": user.name,
"picture": user.picture,
"role": user.role,
"is_active": user.is_active,
"provider": oauth_provider.provider,
"providers": [p.provider for p in user.oauth_providers],
} }
# Generate JWT tokens # Generate JWT tokens
access_token = self.token_service.generate_access_token(user_data) access_token = self.token_service.generate_access_token(
refresh_token = self.token_service.generate_refresh_token(user_data) jwt_user_data
)
refresh_token = self.token_service.generate_refresh_token(
jwt_user_data
)
# Create response and set HTTP-only cookies # Create response and set HTTP-only cookies
response = make_response({ response = jsonify(
"message": "Login successful", {
"user": user_data, "message": "Login successful",
}) "user": jwt_user_data,
}
response.set_cookie(
"access_token",
access_token,
httponly=True,
secure=True,
samesite="Lax",
max_age=15 * 60, # 15 minutes
) )
response.set_cookie( # Set JWT cookies
"refresh_token", set_access_cookies(response, access_token)
refresh_token, set_refresh_cookies(response, refresh_token)
httponly=True,
secure=True,
samesite="Lax",
max_age=7 * 24 * 60 * 60, # 7 days
)
return user_data, response return response
msg = "Failed to get user information from Google" msg = f"Failed to get user information from {provider.display_name}"
raise ValueError(msg) raise ValueError(msg)
def get_current_user(self) -> dict[str, Any] | None: def get_available_providers(self) -> dict[str, Any]:
"""Get current user from access token.""" """Get list of available OAuth providers."""
access_token = request.cookies.get("access_token") if not self.provider_registry:
if not access_token: return {}
return None
return self.token_service.get_user_from_access_token(access_token) providers = self.provider_registry.get_available_providers()
return {
def refresh_tokens(self) -> Any: name: {"name": provider.name, "display_name": provider.display_name}
"""Refresh access token using refresh token.""" for name, provider in providers.items()
refresh_token = request.cookies.get("refresh_token")
if not refresh_token:
return None
payload = self.token_service.verify_token(refresh_token)
if not payload or not self.token_service.is_refresh_token(payload):
return None
# For refresh, we need to get user data (in a real app, from database)
# For now, we'll extract what we can from the refresh token
user_data = {
"id": payload["user_id"],
"email": "", # Would need to fetch from database
"name": "", # Would need to fetch from database
} }
# Generate new access token @jwt_required()
new_access_token = self.token_service.generate_access_token(user_data) def get_current_user(self) -> dict[str, Any] | None:
"""Get current user from JWT token."""
from flask_jwt_extended import get_jwt
response = make_response({"message": "Token refreshed"}) current_user_id = get_jwt_identity()
response.set_cookie( claims = get_jwt()
"access_token",
new_access_token, if current_user_id:
httponly=True, return {
secure=True, "id": current_user_id,
samesite="Lax", "email": claims.get("email", ""),
max_age=15 * 60, # 15 minutes "name": claims.get("name", ""),
"picture": claims.get("picture"),
"role": claims.get("role", "user"),
"is_active": claims.get("is_active", True),
"provider": claims.get("provider", "unknown"),
"providers": claims.get("providers", []),
}
return None
def register_with_password(
self, email: str, password: str, name: str
) -> Any:
"""Register new user with email and password."""
try:
# Create user with password
user = User.create_with_password(email, password, name)
# Prepare user data for JWT token
jwt_user_data = {
"id": str(user.id),
"email": user.email,
"name": user.name,
"picture": user.picture,
"role": user.role,
"is_active": user.is_active,
"provider": "password",
"providers": ["password"],
}
# Generate JWT tokens
access_token = self.token_service.generate_access_token(
jwt_user_data
)
refresh_token = self.token_service.generate_refresh_token(
jwt_user_data
)
# Create response and set HTTP-only cookies
response = jsonify(
{
"message": "Registration successful",
"user": jwt_user_data,
}
)
# Set JWT cookies
set_access_cookies(response, access_token)
set_refresh_cookies(response, refresh_token)
return response
except ValueError as e:
response = jsonify({"error": str(e)})
response.status_code = 400
return response
def login_with_password(self, email: str, password: str) -> Any:
"""Login user with email and password."""
# Authenticate user
user = User.authenticate_with_password(email, password)
if not user:
response = jsonify(
{"error": "Invalid email, password or disabled account"}
)
response.status_code = 401
return response
# Prepare user data for JWT token
oauth_providers = [p.provider for p in user.oauth_providers]
if user.has_password():
oauth_providers.append("password")
jwt_user_data = {
"id": str(user.id),
"email": user.email,
"name": user.name,
"picture": user.picture,
"role": user.role,
"is_active": user.is_active,
"provider": "password",
"providers": oauth_providers,
}
# Generate JWT tokens
access_token = self.token_service.generate_access_token(jwt_user_data)
refresh_token = self.token_service.generate_refresh_token(jwt_user_data)
# Create response and set HTTP-only cookies
response = jsonify(
{
"message": "Login successful",
"user": jwt_user_data,
}
) )
# Set JWT cookies
set_access_cookies(response, access_token)
set_refresh_cookies(response, refresh_token)
return response return response
def logout(self) -> Any: def logout(self) -> Any:
"""Clear authentication cookies.""" """Clear authentication cookies."""
response = make_response({"message": "Logged out successfully"}) response = jsonify({"message": "Logged out successfully"})
response.set_cookie("access_token", "", expires=0) unset_jwt_cookies(response)
response.set_cookie("refresh_token", "", expires=0)
return response return response
def is_authenticated(self) -> bool:
"""Check if user is authenticated."""
return self.get_current_user() is not None

View File

@@ -1,37 +1,148 @@
"""Authentication decorators and middleware.""" """Authentication decorators and middleware."""
from functools import wraps from functools import wraps
from typing import Any, Callable from typing import Any
from flask import jsonify, request from flask import jsonify, request
from flask_jwt_extended import get_jwt, get_jwt_identity, jwt_required
from app.services.token_service import TokenService
def require_auth(f: Callable[..., Any]) -> Callable[..., Any]: def require_auth(f):
"""Decorator to require authentication for routes.""" """Decorator to require authentication for routes."""
@wraps(f) return jwt_required()(f)
def decorated_function(*args: Any, **kwargs: Any) -> Any:
token_service = TokenService()
access_token = request.cookies.get("access_token")
if not access_token:
return jsonify({"error": "Authentication required"}), 401
user_data = token_service.get_user_from_access_token(access_token)
if not user_data:
return jsonify({"error": "Invalid or expired token"}), 401
return f(*args, **kwargs)
return decorated_function
def get_current_user() -> dict[str, Any] | None: def get_current_user() -> dict[str, Any] | None:
"""Helper function to get current user from access token.""" """Helper function to get current user from JWT token."""
token_service = TokenService() try:
access_token = request.cookies.get("access_token") current_user_id = get_jwt_identity()
if not current_user_id:
if not access_token: return None
claims = get_jwt()
is_active = claims.get("is_active", True)
# Check if user is active
if not is_active:
return None
return {
"id": current_user_id,
"email": claims.get("email", ""),
"name": claims.get("name", ""),
"picture": claims.get("picture"),
"role": claims.get("role", "user"),
"is_active": is_active,
"provider": claims.get("provider", "unknown"),
"providers": claims.get("providers", []),
}
except Exception:
return None return None
return token_service.get_user_from_access_token(access_token)
def require_role(required_role: str):
"""Decorator to require specific role for routes."""
def decorator(f):
@wraps(f)
@jwt_required()
def wrapper(*args, **kwargs):
user = get_current_user()
if not user:
return jsonify({"error": "Authentication required"}), 401
if user.get("role") != required_role:
return jsonify({"error": f"Access denied. {required_role.title()} role required"}), 403
return f(*args, **kwargs)
return wrapper
return decorator
def require_admin(f):
"""Decorator to require admin role for routes."""
return require_role("admin")(f)
def require_user_or_admin(f):
"""Decorator to require user or admin role for routes."""
@wraps(f)
@jwt_required()
def wrapper(*args, **kwargs):
user = get_current_user()
if not user:
return jsonify({"error": "Authentication required"}), 401
if user.get("role") not in ["user", "admin"]:
return jsonify({"error": "Access denied"}), 403
return f(*args, **kwargs)
return wrapper
def get_user_from_api_token() -> dict[str, Any] | None:
"""Get user from API token in request headers."""
try:
# Check for API token in Authorization header
auth_header = request.headers.get("Authorization")
if not auth_header:
return None
# Expected format: "Bearer <token>" or "Token <token>"
parts = auth_header.split()
if len(parts) != 2 or parts[0].lower() not in ["bearer", "token"]:
return None
api_token = parts[1]
# Import here to avoid circular imports
from app.models.user import User
user = User.find_by_api_token(api_token)
if user and user.is_active:
return {
"id": str(user.id),
"email": user.email,
"name": user.name,
"picture": user.picture,
"role": user.role,
"is_active": user.is_active,
"provider": "api_token",
"providers": [p.provider for p in user.oauth_providers] + ["api_token"],
}
return None
except Exception:
return None
def require_api_token(f):
"""Decorator to require API token authentication for routes."""
@wraps(f)
def wrapper(*args, **kwargs):
user = get_user_from_api_token()
if not user:
return jsonify({"error": "Valid API token required"}), 401
return f(*args, **kwargs)
return wrapper
def require_auth_or_api_token(f):
"""Decorator to accept either JWT or API token authentication."""
@wraps(f)
def wrapper(*args, **kwargs):
# Try JWT authentication first
try:
user = get_current_user()
if user:
return f(*args, **kwargs)
except Exception:
pass
# Try API token authentication
user = get_user_from_api_token()
if user:
return f(*args, **kwargs)
return jsonify({"error": "Authentication required (JWT or API token)"}), 401
return wrapper

View File

View File

@@ -0,0 +1,68 @@
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
from authlib.integrations.flask_client import OAuth
class OAuthProvider(ABC):
"""Abstract base class for OAuth providers."""
def __init__(self, oauth: OAuth, client_id: str, client_secret: str):
self.oauth = oauth
self.client_id = client_id
self.client_secret = client_secret
self._client = None
@property
@abstractmethod
def name(self) -> str:
"""Provider name (e.g., 'google', 'github')."""
pass
@property
@abstractmethod
def display_name(self) -> str:
"""Human-readable provider name (e.g., 'Google', 'GitHub')."""
pass
@abstractmethod
def get_client_config(self) -> Dict[str, Any]:
"""Return OAuth client configuration."""
pass
@abstractmethod
def get_user_info(self, token: Dict[str, Any]) -> Dict[str, Any]:
"""Extract user information from OAuth token response."""
pass
def get_client(self):
"""Get or create OAuth client."""
if self._client is None:
config = self.get_client_config()
self._client = self.oauth.register(
name=self.name,
client_id=self.client_id,
client_secret=self.client_secret,
**config
)
return self._client
def get_authorization_url(self, redirect_uri: str) -> str:
"""Generate authorization URL for OAuth flow."""
client = self.get_client()
return client.authorize_redirect(redirect_uri).location
def exchange_code_for_token(self, code: str = None, redirect_uri: str = None) -> Dict[str, Any]:
"""Exchange authorization code for access token."""
client = self.get_client()
token = client.authorize_access_token()
return token
def normalize_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize user data to common format."""
return {
'id': user_info.get('id'),
'email': user_info.get('email'),
'name': user_info.get('name'),
'picture': user_info.get('picture'),
'provider': self.name
}

View File

@@ -0,0 +1,52 @@
from typing import Dict, Any
from .base import OAuthProvider
class GitHubOAuthProvider(OAuthProvider):
"""GitHub OAuth provider implementation."""
@property
def name(self) -> str:
return 'github'
@property
def display_name(self) -> str:
return 'GitHub'
def get_client_config(self) -> Dict[str, Any]:
"""Return GitHub OAuth client configuration."""
return {
'access_token_url': 'https://github.com/login/oauth/access_token',
'authorize_url': 'https://github.com/login/oauth/authorize',
'api_base_url': 'https://api.github.com/',
'client_kwargs': {
'scope': 'user:email'
}
}
def get_user_info(self, token: Dict[str, Any]) -> Dict[str, Any]:
"""Extract user information from GitHub OAuth token response."""
client = self.get_client()
# Get user profile
user_resp = client.get('user', token=token)
user_data = user_resp.json()
# Get user email (may be private)
email = user_data.get('email')
if not email:
# If email is private, get from emails endpoint
emails_resp = client.get('user/emails', token=token)
emails = emails_resp.json()
# Find primary email
for email_obj in emails:
if email_obj.get('primary', False):
email = email_obj.get('email')
break
return {
'id': str(user_data.get('id')),
'email': email,
'name': user_data.get('name') or user_data.get('login'),
'picture': user_data.get('avatar_url')
}

View File

@@ -0,0 +1,34 @@
from typing import Any, Dict
from .base import OAuthProvider
class GoogleOAuthProvider(OAuthProvider):
"""Google OAuth provider implementation."""
@property
def name(self) -> str:
return "google"
@property
def display_name(self) -> str:
return "Google"
def get_client_config(self) -> Dict[str, Any]:
"""Return Google OAuth client configuration."""
return {
"server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
"client_kwargs": {"scope": "openid email profile"},
}
def get_user_info(self, token: Dict[str, Any]) -> Dict[str, Any]:
"""Extract user information from Google OAuth token response."""
client = self.get_client()
user_info = client.userinfo(token=token)
return {
"id": user_info.get("sub"),
"email": user_info.get("email"),
"name": user_info.get("name"),
"picture": user_info.get("picture"),
}

View File

@@ -0,0 +1,45 @@
import os
from typing import Dict, Optional
from authlib.integrations.flask_client import OAuth
from .base import OAuthProvider
from .google import GoogleOAuthProvider
from .github import GitHubOAuthProvider
class OAuthProviderRegistry:
"""Registry for OAuth providers."""
def __init__(self, oauth: OAuth):
self.oauth = oauth
self._providers: Dict[str, OAuthProvider] = {}
self._initialize_providers()
def _initialize_providers(self):
"""Initialize available providers based on environment variables."""
# Google OAuth
google_client_id = os.getenv('GOOGLE_CLIENT_ID')
google_client_secret = os.getenv('GOOGLE_CLIENT_SECRET')
if google_client_id and google_client_secret:
self._providers['google'] = GoogleOAuthProvider(
self.oauth, google_client_id, google_client_secret
)
# GitHub OAuth
github_client_id = os.getenv('GITHUB_CLIENT_ID')
github_client_secret = os.getenv('GITHUB_CLIENT_SECRET')
if github_client_id and github_client_secret:
self._providers['github'] = GitHubOAuthProvider(
self.oauth, github_client_id, github_client_secret
)
def get_provider(self, name: str) -> Optional[OAuthProvider]:
"""Get OAuth provider by name."""
return self._providers.get(name)
def get_available_providers(self) -> Dict[str, OAuthProvider]:
"""Get all available providers."""
return self._providers.copy()
def is_provider_available(self, name: str) -> bool:
"""Check if provider is available."""
return name in self._providers

View File

@@ -1,75 +1,24 @@
"""JWT token service for handling access and refresh tokens.""" """JWT token service using Flask-JWT-Extended."""
import os
from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
import jwt from flask_jwt_extended import create_access_token, create_refresh_token
class TokenService: class TokenService:
"""Service for handling JWT tokens.""" """Service for handling JWT tokens using Flask-JWT-Extended."""
def __init__(self) -> None:
"""Initialize the token service."""
self.secret_key = os.environ.get("JWT_SECRET_KEY", "jwt-secret-key")
self.algorithm = "HS256"
self.access_token_expire_minutes = 15
self.refresh_token_expire_days = 7
def generate_access_token(self, user_data: dict[str, Any]) -> str: def generate_access_token(self, user_data: dict[str, Any]) -> str:
"""Generate an access token for the user.""" """Generate an access token for the user."""
payload = { return create_access_token(
"user_id": user_data["id"], identity=user_data["id"],
"email": user_data["email"], additional_claims={
"name": user_data["name"], "email": user_data["email"],
"type": "access", "name": user_data["name"],
"exp": datetime.now(timezone.utc) + timedelta( "picture": user_data.get("picture"),
minutes=self.access_token_expire_minutes },
), )
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
def generate_refresh_token(self, user_data: dict[str, Any]) -> str: def generate_refresh_token(self, user_data: dict[str, Any]) -> str:
"""Generate a refresh token for the user.""" """Generate a refresh token for the user."""
payload = { return create_refresh_token(identity=user_data["id"])
"user_id": user_data["id"],
"type": "refresh",
"exp": datetime.now(timezone.utc) + timedelta(
days=self.refresh_token_expire_days
),
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
def verify_token(self, token: str) -> dict[str, Any] | None:
"""Verify and decode a JWT token."""
try:
payload = jwt.decode(
token, self.secret_key, algorithms=[self.algorithm]
)
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def is_access_token(self, payload: dict[str, Any]) -> bool:
"""Check if the token payload is for an access token."""
return payload.get("type") == "access"
def is_refresh_token(self, payload: dict[str, Any]) -> bool:
"""Check if the token payload is for a refresh token."""
return payload.get("type") == "refresh"
def get_user_from_access_token(self, token: str) -> dict[str, Any] | None:
"""Extract user data from access token."""
payload = self.verify_token(token)
if payload and self.is_access_token(payload):
return {
"id": payload["user_id"],
"email": payload["email"],
"name": payload["name"],
}
return None

View File

@@ -1,5 +1,10 @@
from dotenv import load_dotenv
from app import create_app from app import create_app
# Load environment variables from .env file
load_dotenv()
def main() -> None: def main() -> None:
"""Run the Flask application.""" """Run the Flask application."""

28
migrate_db.py Normal file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Database migration script for Flask-Migrate."""
import os
from flask.cli import FlaskGroup
from app import create_app
from app.database import db
app = create_app()
cli = FlaskGroup(app)
@cli.command()
def init_db():
"""Initialize the database."""
print("Initializing database...")
db.create_all()
print("Database initialized successfully!")
@cli.command()
def reset_db():
"""Reset the database (drop all tables and recreate)."""
print("Resetting database...")
db.drop_all()
db.create_all()
print("Database reset successfully!")
if __name__ == "__main__":
cli()

View File

@@ -5,7 +5,16 @@ description = "Soundboard V2 - Backend"
authors = [{ name = "quaik8", email = "quaik8@gmail.com" }] authors = [{ name = "quaik8", email = "quaik8@gmail.com" }]
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = ["flask==3.1.1", "authlib==1.3.2", "requests==2.32.3", "pyjwt==2.9.0"] dependencies = [
"authlib==1.6.0",
"flask==3.1.1",
"flask-jwt-extended==4.7.1",
"flask-migrate==4.1.0",
"flask-sqlalchemy==3.1.1",
"python-dotenv==1.1.1",
"requests==2.32.4",
"werkzeug==3.1.3",
]
[dependency-groups] [dependency-groups]
dev = ["black==25.1.0", "pytest==8.4.1", "ruff==0.12.1"] dev = ["black==25.1.0", "pytest==8.4.1", "ruff==0.12.1"]

5
reset.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
rm instance/soundboard.db
uv run migrate_db.py init-db
uv run main.py

View File

@@ -1,4 +1,4 @@
"""Tests for authentication routes.""" """Tests for authentication routes with Flask-JWT-Extended."""
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
@@ -12,12 +12,13 @@ def client():
"""Create a test client for the Flask application.""" """Create a test client for the Flask application."""
app = create_app() app = create_app()
app.config["TESTING"] = True app.config["TESTING"] = True
app.config["JWT_COOKIE_SECURE"] = False # Allow cookies in testing
with app.test_client() as client: with app.test_client() as client:
yield client yield client
class TestAuthRoutes: class TestAuthRoutesJWTExtended:
"""Test cases for authentication routes.""" """Test cases for authentication routes with Flask-JWT-Extended."""
@patch("app.routes.auth.auth_service.get_login_url") @patch("app.routes.auth.auth_service.get_login_url")
def test_login_route(self, mock_get_login_url: Mock, client) -> None: def test_login_route(self, mock_get_login_url: Mock, client) -> None:
@@ -30,33 +31,18 @@ class TestAuthRoutes:
assert "login_url" in data assert "login_url" in data
assert data["login_url"] == "https://accounts.google.com/oauth/authorize?..." assert data["login_url"] == "https://accounts.google.com/oauth/authorize?..."
def test_callback_route_no_code(self, client) -> None:
"""Test callback route without authorization code."""
response = client.get("/api/auth/callback")
assert response.status_code == 400
data = response.get_json()
assert data["error"] == "Authorization code not found"
@patch("app.routes.auth.auth_service.handle_callback") @patch("app.routes.auth.auth_service.handle_callback")
def test_callback_route_success(self, mock_handle_callback: Mock, client) -> None: def test_callback_route_success(self, mock_handle_callback: Mock, client) -> None:
"""Test successful callback route.""" """Test successful callback route."""
user_data = {
"id": "123",
"email": "test@example.com",
"name": "Test User"
}
mock_response = Mock() mock_response = Mock()
mock_response.get_json.return_value = { mock_response.get_json.return_value = {
"message": "Login successful", "message": "Login successful",
"user": user_data "user": {"id": "123", "email": "test@example.com", "name": "Test User"}
} }
mock_handle_callback.return_value = (user_data, mock_response) mock_handle_callback.return_value = mock_response
with patch("app.routes.auth.client.get") as mock_get: response = client.get("/api/auth/callback?code=test_code")
mock_get.return_value = mock_response mock_handle_callback.assert_called_once()
response = client.get("/api/auth/callback?code=test_code")
# Since we're returning the mock response directly, we need to verify differently
mock_handle_callback.assert_called_once()
@patch("app.routes.auth.auth_service.handle_callback") @patch("app.routes.auth.auth_service.handle_callback")
def test_callback_route_error(self, mock_handle_callback: Mock, client) -> None: def test_callback_route_error(self, mock_handle_callback: Mock, client) -> None:
@@ -75,32 +61,19 @@ class TestAuthRoutes:
mock_response.get_json.return_value = {"message": "Logged out successfully"} mock_response.get_json.return_value = {"message": "Logged out successfully"}
mock_logout.return_value = mock_response mock_logout.return_value = mock_response
with patch("app.routes.auth.client.get") as mock_get: response = client.get("/api/auth/logout")
mock_get.return_value = mock_response mock_logout.assert_called_once()
response = client.get("/api/auth/logout")
mock_logout.assert_called_once()
@patch("app.routes.auth.auth_service.get_current_user") def test_me_route_not_authenticated(self, client) -> None:
def test_me_route_authenticated(self, mock_get_current_user: Mock, client) -> None:
"""Test /me route when authenticated."""
user_data = {
"id": "123",
"email": "test@example.com",
"name": "Test User"
}
mock_get_current_user.return_value = user_data
response = client.get("/api/auth/me")
assert response.status_code == 200
data = response.get_json()
assert data["user"] == user_data
@patch("app.routes.auth.auth_service.get_current_user")
def test_me_route_not_authenticated(self, mock_get_current_user: Mock, client) -> None:
"""Test /me route when not authenticated.""" """Test /me route when not authenticated."""
mock_get_current_user.return_value = None
response = client.get("/api/auth/me") response = client.get("/api/auth/me")
assert response.status_code == 401 assert response.status_code == 401
data = response.get_json() data = response.get_json()
assert data["error"] == "Not authenticated" assert "msg" in data # Flask-JWT-Extended error format
def test_refresh_route_not_authenticated(self, client) -> None:
"""Test /refresh route when not authenticated."""
response = client.post("/api/auth/refresh")
assert response.status_code == 401
data = response.get_json()
assert "msg" in data # Flask-JWT-Extended error format

View File

@@ -1,12 +1,13 @@
"""Tests for AuthService.""" """Tests for AuthService with Flask-JWT-Extended."""
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from app import create_app
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
class TestAuthService: class TestAuthServiceJWTExtended:
"""Test cases for AuthService.""" """Test cases for AuthService with Flask-JWT-Extended."""
def test_init_without_app(self) -> None: def test_init_without_app(self) -> None:
"""Test initializing AuthService without Flask app.""" """Test initializing AuthService without Flask app."""
@@ -23,59 +24,25 @@ class TestAuthService:
"GOOGLE_CLIENT_SECRET": "test_client_secret" "GOOGLE_CLIENT_SECRET": "test_client_secret"
}.get(key) }.get(key)
mock_app = Mock() app = create_app()
auth_service = AuthService() auth_service = AuthService()
auth_service.init_app(mock_app) auth_service.init_app(app)
auth_service.oauth.init_app.assert_called_once_with(mock_app) # Verify OAuth was initialized
assert auth_service.google is not None
@patch("app.services.auth_service.request") @patch("app.services.auth_service.unset_jwt_cookies")
def test_get_current_user_no_token(self, mock_request: Mock) -> None: @patch("app.services.auth_service.jsonify")
"""Test getting current user when no token exists.""" def test_logout(self, mock_jsonify: Mock, mock_unset: Mock) -> None:
mock_request.cookies.get.return_value = None
auth_service = AuthService()
user = auth_service.get_current_user()
assert user is None
@patch("app.services.auth_service.request")
def test_get_current_user_with_token(self, mock_request: Mock) -> None:
"""Test getting current user when valid token exists."""
mock_request.cookies.get.return_value = "valid.access.token"
auth_service = AuthService()
user_data = {"id": "123", "email": "test@example.com", "name": "Test User"}
with patch.object(auth_service.token_service, 'get_user_from_access_token', return_value=user_data):
user = auth_service.get_current_user()
assert user == user_data
@patch("app.services.auth_service.request")
def test_is_authenticated_false(self, mock_request: Mock) -> None:
"""Test authentication check when not authenticated."""
mock_request.cookies.get.return_value = None
auth_service = AuthService()
assert not auth_service.is_authenticated()
@patch("app.services.auth_service.request")
def test_is_authenticated_true(self, mock_request: Mock) -> None:
"""Test authentication check when authenticated."""
mock_request.cookies.get.return_value = "valid.access.token"
auth_service = AuthService()
user_data = {"id": "123", "email": "test@example.com", "name": "Test User"}
with patch.object(auth_service.token_service, 'get_user_from_access_token', return_value=user_data):
assert auth_service.is_authenticated()
@patch("app.services.auth_service.make_response")
def test_logout(self, mock_make_response: Mock) -> None:
"""Test logout functionality.""" """Test logout functionality."""
mock_response = Mock() app = create_app()
mock_make_response.return_value = mock_response with app.app_context():
mock_response = Mock()
auth_service = AuthService() mock_jsonify.return_value = mock_response
result = auth_service.logout()
auth_service = AuthService()
assert result == mock_response result = auth_service.logout()
mock_response.set_cookie.assert_any_call("access_token", "", expires=0)
mock_response.set_cookie.assert_any_call("refresh_token", "", expires=0) assert result == mock_response
mock_unset.assert_called_once_with(mock_response)
mock_jsonify.assert_called_once_with({"message": "Logged out successfully"})

View File

@@ -0,0 +1,57 @@
"""Tests for TokenService using Flask-JWT-Extended."""
from unittest.mock import patch
from app import create_app
from app.services.token_service import TokenService
class TestTokenServiceJWTExtended:
"""Test cases for TokenService with Flask-JWT-Extended."""
def test_generate_access_token(self) -> None:
"""Test access token generation."""
app = create_app()
with app.app_context():
token_service = TokenService()
user_data = {
"id": "123",
"email": "test@example.com",
"name": "Test User",
"picture": "https://example.com/pic.jpg"
}
token = token_service.generate_access_token(user_data)
assert isinstance(token, str)
assert len(token) > 0
def test_generate_refresh_token(self) -> None:
"""Test refresh token generation."""
app = create_app()
with app.app_context():
token_service = TokenService()
user_data = {
"id": "123",
"email": "test@example.com",
"name": "Test User"
}
token = token_service.generate_refresh_token(user_data)
assert isinstance(token, str)
assert len(token) > 0
def test_generate_tokens_different(self) -> None:
"""Test that access and refresh tokens are different."""
app = create_app()
with app.app_context():
token_service = TokenService()
user_data = {
"id": "123",
"email": "test@example.com",
"name": "Test User"
}
access_token = token_service.generate_access_token(user_data)
refresh_token = token_service.generate_refresh_token(user_data)
assert access_token != refresh_token

340
uv.lock generated
View File

@@ -2,6 +2,32 @@ version = 1
revision = 1 revision = 1
requires-python = ">=3.12" requires-python = ">=3.12"
[[package]]
name = "alembic"
version = "1.16.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9c/35/116797ff14635e496bbda0c168987f5326a6555b09312e9b817e360d1f56/alembic-1.16.2.tar.gz", hash = "sha256:e53c38ff88dadb92eb22f8b150708367db731d58ad7e9d417c9168ab516cbed8", size = 1963563 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/e2/88e425adac5ad887a087c38d04fe2030010572a3e0e627f8a6e8c33eeda8/alembic-1.16.2-py3-none-any.whl", hash = "sha256:5f42e9bd0afdbd1d5e3ad856c01754530367debdebf21ed6894e34af52b3bb03", size = 242717 },
]
[[package]]
name = "authlib"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/9d/b1e08d36899c12c8b894a44a5583ee157789f26fc4b176f8e4b6217b56e1/authlib-1.6.0.tar.gz", hash = "sha256:4367d32031b7af175ad3a323d571dc7257b7099d55978087ceae4a0d88cd3210", size = 158371 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/29/587c189bbab1ccc8c86a03a5d0e13873df916380ef1be461ebe6acebf48d/authlib-1.6.0-py2.py3-none-any.whl", hash = "sha256:91685589498f79e8655e8a8947431ad6288831d643f11c55c2143ffcc738048d", size = 239981 },
]
[[package]] [[package]]
name = "black" name = "black"
version = "25.1.0" version = "25.1.0"
@@ -35,6 +61,83 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 }, { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 },
] ]
[[package]]
name = "certifi"
version = "2025.6.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650 },
]
[[package]]
name = "cffi"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 },
{ url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 },
{ url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 },
{ url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 },
{ url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 },
{ url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 },
{ url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 },
{ url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 },
{ url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 },
{ url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 },
{ url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 },
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 },
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 },
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 },
{ url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 },
{ url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 },
{ url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 },
{ url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 },
{ url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 },
{ url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 },
{ url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 },
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
]
[[package]]
name = "charset-normalizer"
version = "3.4.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936 },
{ url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790 },
{ url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924 },
{ url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626 },
{ url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567 },
{ url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957 },
{ url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408 },
{ url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399 },
{ url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815 },
{ url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537 },
{ url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565 },
{ url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357 },
{ url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776 },
{ url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622 },
{ url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435 },
{ url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653 },
{ url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231 },
{ url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243 },
{ url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442 },
{ url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147 },
{ url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057 },
{ url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454 },
{ url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174 },
{ url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166 },
{ url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064 },
{ url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641 },
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 },
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.2.1" version = "8.2.1"
@@ -56,6 +159,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
] ]
[[package]]
name = "cryptography"
version = "45.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/1c/92637793de053832523b410dbe016d3f5c11b41d0cf6eef8787aabb51d41/cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069", size = 7055712 },
{ url = "https://files.pythonhosted.org/packages/ba/14/93b69f2af9ba832ad6618a03f8a034a5851dc9a3314336a3d71c252467e1/cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d", size = 4205335 },
{ url = "https://files.pythonhosted.org/packages/67/30/fae1000228634bf0b647fca80403db5ca9e3933b91dd060570689f0bd0f7/cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036", size = 4431487 },
{ url = "https://files.pythonhosted.org/packages/6d/5a/7dffcf8cdf0cb3c2430de7404b327e3db64735747d641fc492539978caeb/cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e", size = 4208922 },
{ url = "https://files.pythonhosted.org/packages/c6/f3/528729726eb6c3060fa3637253430547fbaaea95ab0535ea41baa4a6fbd8/cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2", size = 3900433 },
{ url = "https://files.pythonhosted.org/packages/d9/4a/67ba2e40f619e04d83c32f7e1d484c1538c0800a17c56a22ff07d092ccc1/cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b", size = 4464163 },
{ url = "https://files.pythonhosted.org/packages/7e/9a/b4d5aa83661483ac372464809c4b49b5022dbfe36b12fe9e323ca8512420/cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1", size = 4208687 },
{ url = "https://files.pythonhosted.org/packages/db/b7/a84bdcd19d9c02ec5807f2ec2d1456fd8451592c5ee353816c09250e3561/cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999", size = 4463623 },
{ url = "https://files.pythonhosted.org/packages/d8/84/69707d502d4d905021cac3fb59a316344e9f078b1da7fb43ecde5e10840a/cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750", size = 4332447 },
{ url = "https://files.pythonhosted.org/packages/f3/ee/d4f2ab688e057e90ded24384e34838086a9b09963389a5ba6854b5876598/cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2", size = 4572830 },
{ url = "https://files.pythonhosted.org/packages/70/d4/994773a261d7ff98034f72c0e8251fe2755eac45e2265db4c866c1c6829c/cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257", size = 2932769 },
{ url = "https://files.pythonhosted.org/packages/5a/42/c80bd0b67e9b769b364963b5252b17778a397cefdd36fa9aa4a5f34c599a/cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8", size = 3410441 },
{ url = "https://files.pythonhosted.org/packages/ce/0b/2488c89f3a30bc821c9d96eeacfcab6ff3accc08a9601ba03339c0fd05e5/cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723", size = 7031836 },
{ url = "https://files.pythonhosted.org/packages/fe/51/8c584ed426093aac257462ae62d26ad61ef1cbf5b58d8b67e6e13c39960e/cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637", size = 4195746 },
{ url = "https://files.pythonhosted.org/packages/5c/7d/4b0ca4d7af95a704eef2f8f80a8199ed236aaf185d55385ae1d1610c03c2/cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d", size = 4424456 },
{ url = "https://files.pythonhosted.org/packages/1d/45/5fabacbc6e76ff056f84d9f60eeac18819badf0cefc1b6612ee03d4ab678/cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee", size = 4198495 },
{ url = "https://files.pythonhosted.org/packages/55/b7/ffc9945b290eb0a5d4dab9b7636706e3b5b92f14ee5d9d4449409d010d54/cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff", size = 3885540 },
{ url = "https://files.pythonhosted.org/packages/7f/e3/57b010282346980475e77d414080acdcb3dab9a0be63071efc2041a2c6bd/cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6", size = 4452052 },
{ url = "https://files.pythonhosted.org/packages/37/e6/ddc4ac2558bf2ef517a358df26f45bc774a99bf4653e7ee34b5e749c03e3/cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad", size = 4198024 },
{ url = "https://files.pythonhosted.org/packages/3a/c0/85fa358ddb063ec588aed4a6ea1df57dc3e3bc1712d87c8fa162d02a65fc/cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6", size = 4451442 },
{ url = "https://files.pythonhosted.org/packages/33/67/362d6ec1492596e73da24e669a7fbbaeb1c428d6bf49a29f7a12acffd5dc/cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872", size = 4325038 },
{ url = "https://files.pythonhosted.org/packages/53/75/82a14bf047a96a1b13ebb47fb9811c4f73096cfa2e2b17c86879687f9027/cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4", size = 4560964 },
{ url = "https://files.pythonhosted.org/packages/cd/37/1a3cba4c5a468ebf9b95523a5ef5651244693dc712001e276682c278fc00/cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97", size = 2924557 },
{ url = "https://files.pythonhosted.org/packages/2a/4b/3256759723b7e66380397d958ca07c59cfc3fb5c794fb5516758afd05d41/cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22", size = 3395508 },
]
[[package]] [[package]]
name = "flask" name = "flask"
version = "3.1.1" version = "3.1.1"
@@ -73,6 +211,89 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305 }, { url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305 },
] ]
[[package]]
name = "flask-jwt-extended"
version = "4.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "pyjwt" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/51/16/96b101f18cba17ecce3225ab07bc4c8f23e6befd8552dbbed87482e7c7fb/flask_jwt_extended-4.7.1.tar.gz", hash = "sha256:8085d6757505b6f3291a2638c84d207e8f0ad0de662d1f46aa2f77e658a0c976", size = 34411 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/34/9a91da47b1565811ab4aa5fb134632c8d1757960bfa7d457f486947c4d75/Flask_JWT_Extended-4.7.1-py2.py3-none-any.whl", hash = "sha256:52f35bf0985354d7fb7b876e2eb0e0b141aaff865a22ff6cc33d9a18aa987978", size = 22588 },
]
[[package]]
name = "flask-migrate"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alembic" },
{ name = "flask" },
{ name = "flask-sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/47c7b3c93855ceffc2eabfa271782332942443321a07de193e4198f920cf/flask_migrate-4.1.0.tar.gz", hash = "sha256:1a336b06eb2c3ace005f5f2ded8641d534c18798d64061f6ff11f79e1434126d", size = 21965 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/c4/3f329b23d769fe7628a5fc57ad36956f1fb7132cf8837be6da762b197327/Flask_Migrate-4.1.0-py3-none-any.whl", hash = "sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d", size = 21237 },
]
[[package]]
name = "flask-sqlalchemy"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125 },
]
[[package]]
name = "greenlet"
version = "3.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/94/ad0d435f7c48debe960c53b8f60fb41c2026b1d0fa4a99a1cb17c3461e09/greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d", size = 271992 },
{ url = "https://files.pythonhosted.org/packages/93/5d/7c27cf4d003d6e77749d299c7c8f5fd50b4f251647b5c2e97e1f20da0ab5/greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b", size = 638820 },
{ url = "https://files.pythonhosted.org/packages/c6/7e/807e1e9be07a125bb4c169144937910bf59b9d2f6d931578e57f0bce0ae2/greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d", size = 653046 },
{ url = "https://files.pythonhosted.org/packages/9d/ab/158c1a4ea1068bdbc78dba5a3de57e4c7aeb4e7fa034320ea94c688bfb61/greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264", size = 647701 },
{ url = "https://files.pythonhosted.org/packages/cc/0d/93729068259b550d6a0288da4ff72b86ed05626eaf1eb7c0d3466a2571de/greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688", size = 649747 },
{ url = "https://files.pythonhosted.org/packages/f6/f6/c82ac1851c60851302d8581680573245c8fc300253fc1ff741ae74a6c24d/greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb", size = 605461 },
{ url = "https://files.pythonhosted.org/packages/98/82/d022cf25ca39cf1200650fc58c52af32c90f80479c25d1cbf57980ec3065/greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c", size = 1121190 },
{ url = "https://files.pythonhosted.org/packages/f5/e1/25297f70717abe8104c20ecf7af0a5b82d2f5a980eb1ac79f65654799f9f/greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163", size = 1149055 },
{ url = "https://files.pythonhosted.org/packages/1f/8f/8f9e56c5e82eb2c26e8cde787962e66494312dc8cb261c460e1f3a9c88bc/greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849", size = 297817 },
{ url = "https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size = 270732 },
{ url = "https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size = 639033 },
{ url = "https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size = 652999 },
{ url = "https://files.pythonhosted.org/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95", size = 647368 },
{ url = "https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size = 650037 },
{ url = "https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size = 608402 },
{ url = "https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size = 1119577 },
{ url = "https://files.pythonhosted.org/packages/86/94/1fc0cc068cfde885170e01de40a619b00eaa8f2916bf3541744730ffb4c3/greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36", size = 1147121 },
{ url = "https://files.pythonhosted.org/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3", size = 297603 },
{ url = "https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size = 271479 },
{ url = "https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size = 683952 },
{ url = "https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size = 696917 },
{ url = "https://files.pythonhosted.org/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a", size = 692443 },
{ url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995 },
{ url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320 },
{ url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.1.0" version = "2.1.0"
@@ -103,6 +324,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
] ]
[[package]]
name = "mako"
version = "1.3.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509 },
]
[[package]] [[package]]
name = "markupsafe" name = "markupsafe"
version = "3.0.2" version = "3.0.2"
@@ -186,6 +419,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
] ]
[[package]]
name = "pycparser"
version = "2.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.2" version = "2.19.2"
@@ -195,6 +437,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
] ]
[[package]]
name = "pyjwt"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.4.1" version = "8.4.1"
@@ -211,6 +462,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474 }, { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474 },
] ]
[[package]]
name = "python-dotenv"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556 },
]
[[package]]
name = "requests"
version = "2.32.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847 },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.12.1" version = "0.12.1"
@@ -241,7 +516,14 @@ name = "sdb-backend"
version = "2.0.0" version = "2.0.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "authlib" },
{ name = "flask" }, { name = "flask" },
{ name = "flask-jwt-extended" },
{ name = "flask-migrate" },
{ name = "flask-sqlalchemy" },
{ name = "python-dotenv" },
{ name = "requests" },
{ name = "werkzeug" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -252,7 +534,16 @@ dev = [
] ]
[package.metadata] [package.metadata]
requires-dist = [{ name = "flask", specifier = "==3.1.1" }] requires-dist = [
{ name = "authlib", specifier = "==1.6.0" },
{ name = "flask", specifier = "==3.1.1" },
{ name = "flask-jwt-extended", specifier = "==4.7.1" },
{ name = "flask-migrate", specifier = "==4.1.0" },
{ name = "flask-sqlalchemy", specifier = "==3.1.1" },
{ name = "python-dotenv", specifier = "==1.1.1" },
{ name = "requests", specifier = "==2.32.4" },
{ name = "werkzeug", specifier = "==3.1.3" },
]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
@@ -261,6 +552,53 @@ dev = [
{ name = "ruff", specifier = "==0.12.1" }, { name = "ruff", specifier = "==0.12.1" },
] ]
[[package]]
name = "sqlalchemy"
version = "2.0.41"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/2a/f1f4e068b371154740dd10fb81afb5240d5af4aa0087b88d8b308b5429c2/sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9", size = 2119645 },
{ url = "https://files.pythonhosted.org/packages/9b/e8/c664a7e73d36fbfc4730f8cf2bf930444ea87270f2825efbe17bf808b998/sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1", size = 2107399 },
{ url = "https://files.pythonhosted.org/packages/5c/78/8a9cf6c5e7135540cb682128d091d6afa1b9e48bd049b0d691bf54114f70/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70", size = 3293269 },
{ url = "https://files.pythonhosted.org/packages/3c/35/f74add3978c20de6323fb11cb5162702670cc7a9420033befb43d8d5b7a4/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e", size = 3303364 },
{ url = "https://files.pythonhosted.org/packages/6a/d4/c990f37f52c3f7748ebe98883e2a0f7d038108c2c5a82468d1ff3eec50b7/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078", size = 3229072 },
{ url = "https://files.pythonhosted.org/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae", size = 3268074 },
{ url = "https://files.pythonhosted.org/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514 },
{ url = "https://files.pythonhosted.org/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557 },
{ url = "https://files.pythonhosted.org/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443", size = 2115491 },
{ url = "https://files.pythonhosted.org/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc", size = 2102827 },
{ url = "https://files.pythonhosted.org/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1", size = 3225224 },
{ url = "https://files.pythonhosted.org/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a", size = 3230045 },
{ url = "https://files.pythonhosted.org/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d", size = 3159357 },
{ url = "https://files.pythonhosted.org/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size = 3197511 },
{ url = "https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420 },
{ url = "https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329 },
{ url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224 },
]
[[package]]
name = "typing-extensions"
version = "4.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839 },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 },
]
[[package]] [[package]]
name = "werkzeug" name = "werkzeug"
version = "3.1.3" version = "3.1.3"