auth google + jwt

This commit is contained in:
JSC
2025-06-27 13:14:29 +02:00
commit 8e2dbd8723
21 changed files with 1107 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

0
README.md Normal file
View File

23
app/__init__.py Normal file
View File

@@ -0,0 +1,23 @@
import os
from flask import Flask
from app.services.auth_service import AuthService
def create_app():
"""Create and configure the Flask application."""
app = Flask(__name__)
# Configure session
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-key")
# Initialize authentication service
auth_service = AuthService(app)
# Register blueprints
from app.routes import main, auth
app.register_blueprint(main.bp, url_prefix="/api")
app.register_blueprint(auth.bp, url_prefix="/api/auth")
return app

0
app/routes/__init__.py Normal file
View File

52
app/routes/auth.py Normal file
View File

@@ -0,0 +1,52 @@
"""Authentication routes."""
from flask import Blueprint, url_for
from app.services.auth_service import AuthService
bp = Blueprint("auth", __name__)
auth_service = AuthService()
@bp.route("/login")
def login() -> dict[str, str]:
"""Initiate Google OAuth login."""
redirect_uri = url_for("auth.callback", _external=True)
login_url = auth_service.get_login_url(redirect_uri)
return {"login_url": login_url}
@bp.route("/callback")
def callback():
"""Handle OAuth callback from Google."""
try:
user_data, response = auth_service.handle_callback()
return response
except Exception as e:
return {"error": str(e)}, 400
@bp.route("/logout")
def logout():
"""Logout current user."""
return auth_service.logout()
@bp.route("/me")
def me() -> dict[str, str] | tuple[dict[str, str], int]:
"""Get current user information."""
user = auth_service.get_current_user()
if not user:
return {"error": "Not authenticated"}, 401
return {"user": user}
@bp.route("/refresh")
def refresh():
"""Refresh access token using refresh token."""
response = auth_service.refresh_tokens()
if not response:
return {"error": "Invalid or expired refresh token"}, 401
return response

38
app/routes/main.py Normal file
View File

@@ -0,0 +1,38 @@
"""Main routes for the application."""
from flask import Blueprint
from app.services.decorators import get_current_user, require_auth
from app.services.greeting_service import GreetingService
bp = Blueprint("main", __name__)
@bp.route("/")
def index() -> dict[str, str]:
"""Root endpoint that returns a greeting."""
return GreetingService.get_greeting()
@bp.route("/hello")
@bp.route("/hello/<name>")
def hello(name: str | None = None) -> dict[str, str]:
"""Hello endpoint with optional name parameter."""
return GreetingService.get_greeting(name)
@bp.route("/protected")
@require_auth
def protected() -> dict[str, str]:
"""Protected endpoint that requires authentication."""
user = get_current_user()
return {
"message": f"Hello {user['name']}, this is a protected endpoint!",
"user": user
}
@bp.route("/health")
def health() -> dict[str, str]:
"""Health check endpoint."""
return {"status": "ok"}

0
app/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,143 @@
"""Authentication service for Google OAuth."""
import os
from typing import Any
from authlib.integrations.flask_client import OAuth
from flask import Flask, make_response, request
from app.services.token_service import TokenService
class AuthService:
"""Service for handling Google OAuth authentication."""
def __init__(self, app: Flask | None = None) -> None:
"""Initialize the authentication service."""
self.oauth = OAuth()
self.google = None
self.token_service = TokenService()
if app:
self.init_app(app)
def init_app(self, app: Flask) -> None:
"""Initialize the service with Flask app."""
self.oauth.init_app(app)
# Configure Google OAuth
self.google = self.oauth.register(
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:
"""Generate Google OAuth login URL."""
if not self.google:
msg = "Google OAuth not configured"
raise RuntimeError(msg)
return self.google.authorize_redirect(redirect_uri).location
def handle_callback(self) -> tuple[dict[str, Any], Any]:
"""Handle OAuth callback and exchange code for token."""
if not self.google:
msg = "Google OAuth not configured"
raise RuntimeError(msg)
token = self.google.authorize_access_token()
user_info = token.get("userinfo")
if user_info:
user_data = {
"id": user_info["sub"],
"email": user_info["email"],
"name": user_info["name"],
"picture": user_info.get("picture"),
}
# Generate JWT tokens
access_token = self.token_service.generate_access_token(user_data)
refresh_token = self.token_service.generate_refresh_token(user_data)
# Create response and set HTTP-only cookies
response = make_response({
"message": "Login successful",
"user": user_data,
})
response.set_cookie(
"access_token",
access_token,
httponly=True,
secure=True,
samesite="Lax",
max_age=15 * 60, # 15 minutes
)
response.set_cookie(
"refresh_token",
refresh_token,
httponly=True,
secure=True,
samesite="Lax",
max_age=7 * 24 * 60 * 60, # 7 days
)
return user_data, response
msg = "Failed to get user information from Google"
raise ValueError(msg)
def get_current_user(self) -> dict[str, Any] | None:
"""Get current user from access token."""
access_token = request.cookies.get("access_token")
if not access_token:
return None
return self.token_service.get_user_from_access_token(access_token)
def refresh_tokens(self) -> Any:
"""Refresh access token using refresh token."""
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
new_access_token = self.token_service.generate_access_token(user_data)
response = make_response({"message": "Token refreshed"})
response.set_cookie(
"access_token",
new_access_token,
httponly=True,
secure=True,
samesite="Lax",
max_age=15 * 60, # 15 minutes
)
return response
def logout(self) -> Any:
"""Clear authentication cookies."""
response = make_response({"message": "Logged out successfully"})
response.set_cookie("access_token", "", expires=0)
response.set_cookie("refresh_token", "", expires=0)
return response
def is_authenticated(self) -> bool:
"""Check if user is authenticated."""
return self.get_current_user() is not None

View File

@@ -0,0 +1,37 @@
"""Authentication decorators and middleware."""
from functools import wraps
from typing import Any, Callable
from flask import jsonify, request
from app.services.token_service import TokenService
def require_auth(f: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator to require authentication for routes."""
@wraps(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:
"""Helper function to get current user from access token."""
token_service = TokenService()
access_token = request.cookies.get("access_token")
if not access_token:
return None
return token_service.get_user_from_access_token(access_token)

View File

@@ -0,0 +1,22 @@
"""Service for handling greeting-related business logic."""
class GreetingService:
"""Service for greeting operations."""
@staticmethod
def get_greeting(name: str | None = None) -> dict[str, str]:
"""Get a greeting message.
Args:
name: Optional name to personalize the greeting
Returns:
Dictionary containing the greeting message
"""
if name:
message = f"Hello, {name}!"
else:
message = "Hello from backend!"
return {"message": message}

View File

@@ -0,0 +1,75 @@
"""JWT token service for handling access and refresh tokens."""
import os
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
class TokenService:
"""Service for handling JWT tokens."""
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:
"""Generate an access token for the user."""
payload = {
"user_id": user_data["id"],
"email": user_data["email"],
"name": user_data["name"],
"type": "access",
"exp": datetime.now(timezone.utc) + timedelta(
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:
"""Generate a refresh token for the user."""
payload = {
"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

11
main.py Normal file
View File

@@ -0,0 +1,11 @@
from app import create_app
def main() -> None:
"""Run the Flask application."""
app = create_app()
app.run(debug=True, host="0.0.0.0", port=5000)
if __name__ == "__main__":
main()

22
pyproject.toml Normal file
View File

@@ -0,0 +1,22 @@
[project]
name = "sdb-backend"
version = "2.0.0"
description = "Soundboard V2 - Backend"
authors = [{ name = "quaik8", email = "quaik8@gmail.com" }]
readme = "README.md"
requires-python = ">=3.12"
dependencies = ["flask==3.1.1", "authlib==1.3.2", "requests==2.32.3", "pyjwt==2.9.0"]
[dependency-groups]
dev = ["black==25.1.0", "pytest==8.4.1", "ruff==0.12.1"]
[tool.black]
line-length = 80
[tool.ruff]
line-length = 80
lint.select = ["ALL"]
lint.ignore = [
"D100", # Missing docstring in public module
"D104", # Missing docstring in public package
]

0
tests/__init__.py Normal file
View File

106
tests/test_auth_routes.py Normal file
View File

@@ -0,0 +1,106 @@
"""Tests for authentication routes."""
from unittest.mock import Mock, patch
import pytest
from app import create_app
@pytest.fixture
def client():
"""Create a test client for the Flask application."""
app = create_app()
app.config["TESTING"] = True
with app.test_client() as client:
yield client
class TestAuthRoutes:
"""Test cases for authentication routes."""
@patch("app.routes.auth.auth_service.get_login_url")
def test_login_route(self, mock_get_login_url: Mock, client) -> None:
"""Test the login route."""
mock_get_login_url.return_value = "https://accounts.google.com/oauth/authorize?..."
response = client.get("/api/auth/login")
assert response.status_code == 200
data = response.get_json()
assert "login_url" in data
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")
def test_callback_route_success(self, mock_handle_callback: Mock, client) -> None:
"""Test successful callback route."""
user_data = {
"id": "123",
"email": "test@example.com",
"name": "Test User"
}
mock_response = Mock()
mock_response.get_json.return_value = {
"message": "Login successful",
"user": user_data
}
mock_handle_callback.return_value = (user_data, mock_response)
with patch("app.routes.auth.client.get") as mock_get:
mock_get.return_value = mock_response
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")
def test_callback_route_error(self, mock_handle_callback: Mock, client) -> None:
"""Test callback route with error."""
mock_handle_callback.side_effect = Exception("OAuth error")
response = client.get("/api/auth/callback?code=test_code")
assert response.status_code == 400
data = response.get_json()
assert data["error"] == "OAuth error"
@patch("app.routes.auth.auth_service.logout")
def test_logout_route(self, mock_logout: Mock, client) -> None:
"""Test logout route."""
mock_response = Mock()
mock_response.get_json.return_value = {"message": "Logged out successfully"}
mock_logout.return_value = mock_response
with patch("app.routes.auth.client.get") as mock_get:
mock_get.return_value = mock_response
response = client.get("/api/auth/logout")
mock_logout.assert_called_once()
@patch("app.routes.auth.auth_service.get_current_user")
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."""
mock_get_current_user.return_value = None
response = client.get("/api/auth/me")
assert response.status_code == 401
data = response.get_json()
assert data["error"] == "Not authenticated"

View File

@@ -0,0 +1,81 @@
"""Tests for AuthService."""
from unittest.mock import Mock, patch
from app.services.auth_service import AuthService
class TestAuthService:
"""Test cases for AuthService."""
def test_init_without_app(self) -> None:
"""Test initializing AuthService without Flask app."""
auth_service = AuthService()
assert auth_service.oauth is not None
assert auth_service.google is None
assert auth_service.token_service is not None
@patch("app.services.auth_service.os.getenv")
def test_init_app(self, mock_getenv: Mock) -> None:
"""Test initializing AuthService with Flask app."""
mock_getenv.side_effect = lambda key: {
"GOOGLE_CLIENT_ID": "test_client_id",
"GOOGLE_CLIENT_SECRET": "test_client_secret"
}.get(key)
mock_app = Mock()
auth_service = AuthService()
auth_service.init_app(mock_app)
auth_service.oauth.init_app.assert_called_once_with(mock_app)
@patch("app.services.auth_service.request")
def test_get_current_user_no_token(self, mock_request: Mock) -> None:
"""Test getting current user when no token exists."""
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."""
mock_response = Mock()
mock_make_response.return_value = mock_response
auth_service = AuthService()
result = auth_service.logout()
assert result == mock_response
mock_response.set_cookie.assert_any_call("access_token", "", expires=0)
mock_response.set_cookie.assert_any_call("refresh_token", "", expires=0)

View File

@@ -0,0 +1,22 @@
"""Tests for GreetingService."""
from app.services.greeting_service import GreetingService
class TestGreetingService:
"""Test cases for GreetingService."""
def test_get_greeting_without_name(self) -> None:
"""Test getting greeting without providing a name."""
result = GreetingService.get_greeting()
assert result == {"message": "Hello from backend!"}
def test_get_greeting_with_name(self) -> None:
"""Test getting greeting with a name."""
result = GreetingService.get_greeting("Alice")
assert result == {"message": "Hello, Alice!"}
def test_get_greeting_with_empty_string(self) -> None:
"""Test getting greeting with empty string name."""
result = GreetingService.get_greeting("")
assert result == {"message": "Hello from backend!"}

49
tests/test_routes.py Normal file
View File

@@ -0,0 +1,49 @@
"""Tests for routes."""
import pytest
from app import create_app
@pytest.fixture
def client():
"""Create a test client for the Flask application."""
app = create_app()
app.config["TESTING"] = True
with app.test_client() as client:
yield client
class TestMainRoutes:
"""Test cases for main routes."""
def test_index_route(self, client) -> None:
"""Test the index route."""
response = client.get("/api/")
assert response.status_code == 200
assert response.get_json() == {"message": "Hello from backend!"}
def test_hello_route_without_name(self, client) -> None:
"""Test hello route without name parameter."""
response = client.get("/api/hello")
assert response.status_code == 200
assert response.get_json() == {"message": "Hello from backend!"}
def test_hello_route_with_name(self, client) -> None:
"""Test hello route with name parameter."""
response = client.get("/api/hello/Alice")
assert response.status_code == 200
assert response.get_json() == {"message": "Hello, Alice!"}
def test_health_route(self, client) -> None:
"""Test health check route."""
response = client.get("/api/health")
assert response.status_code == 200
assert response.get_json() == {"status": "ok"}
def test_protected_route_without_auth(self, client) -> None:
"""Test protected route without authentication."""
response = client.get("/api/protected")
assert response.status_code == 401
data = response.get_json()
assert data["error"] == "Authentication required"

141
tests/test_token_service.py Normal file
View File

@@ -0,0 +1,141 @@
"""Tests for TokenService."""
from datetime import datetime, timezone
from unittest.mock import patch
import jwt
import pytest
from app.services.token_service import TokenService
class TestTokenService:
"""Test cases for TokenService."""
def test_init(self) -> None:
"""Test TokenService initialization."""
token_service = TokenService()
assert token_service.algorithm == "HS256"
assert token_service.access_token_expire_minutes == 15
assert token_service.refresh_token_expire_days == 7
def test_generate_access_token(self) -> None:
"""Test access token generation."""
token_service = TokenService()
user_data = {
"id": "123",
"email": "test@example.com",
"name": "Test User"
}
token = token_service.generate_access_token(user_data)
assert isinstance(token, str)
# Verify token content
payload = jwt.decode(token, token_service.secret_key, algorithms=[token_service.algorithm])
assert payload["user_id"] == "123"
assert payload["email"] == "test@example.com"
assert payload["name"] == "Test User"
assert payload["type"] == "access"
def test_generate_refresh_token(self) -> None:
"""Test refresh token generation."""
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)
# Verify token content
payload = jwt.decode(token, token_service.secret_key, algorithms=[token_service.algorithm])
assert payload["user_id"] == "123"
assert payload["type"] == "refresh"
def test_verify_valid_token(self) -> None:
"""Test verifying a valid token."""
token_service = TokenService()
user_data = {"id": "123", "email": "test@example.com", "name": "Test User"}
token = token_service.generate_access_token(user_data)
payload = token_service.verify_token(token)
assert payload is not None
assert payload["user_id"] == "123"
assert payload["type"] == "access"
def test_verify_invalid_token(self) -> None:
"""Test verifying an invalid token."""
token_service = TokenService()
payload = token_service.verify_token("invalid.token.here")
assert payload is None
@patch("app.services.token_service.datetime")
def test_verify_expired_token(self, mock_datetime) -> None:
"""Test verifying an expired token."""
# Set up mock to return a past time for token generation
past_time = datetime(2020, 1, 1, tzinfo=timezone.utc)
mock_datetime.now.return_value = past_time
mock_datetime.UTC = timezone.utc
token_service = TokenService()
user_data = {"id": "123", "email": "test@example.com", "name": "Test User"}
token = token_service.generate_access_token(user_data)
# Reset mock to current time for verification
mock_datetime.now.return_value = datetime.now(timezone.utc)
payload = token_service.verify_token(token)
assert payload is None
def test_is_access_token(self) -> None:
"""Test access token type checking."""
token_service = TokenService()
access_payload = {"type": "access", "user_id": "123"}
refresh_payload = {"type": "refresh", "user_id": "123"}
assert token_service.is_access_token(access_payload)
assert not token_service.is_access_token(refresh_payload)
def test_is_refresh_token(self) -> None:
"""Test refresh token type checking."""
token_service = TokenService()
access_payload = {"type": "access", "user_id": "123"}
refresh_payload = {"type": "refresh", "user_id": "123"}
assert token_service.is_refresh_token(refresh_payload)
assert not token_service.is_refresh_token(access_payload)
def test_get_user_from_access_token_valid(self) -> None:
"""Test extracting user from valid access token."""
token_service = TokenService()
user_data = {"id": "123", "email": "test@example.com", "name": "Test User"}
token = token_service.generate_access_token(user_data)
extracted_user = token_service.get_user_from_access_token(token)
assert extracted_user == user_data
def test_get_user_from_access_token_refresh_token(self) -> None:
"""Test extracting user from refresh token (should fail)."""
token_service = TokenService()
user_data = {"id": "123", "email": "test@example.com", "name": "Test User"}
token = token_service.generate_refresh_token(user_data)
extracted_user = token_service.get_user_from_access_token(token)
assert extracted_user is None
def test_get_user_from_access_token_invalid(self) -> None:
"""Test extracting user from invalid token."""
token_service = TokenService()
extracted_user = token_service.get_user_from_access_token("invalid.token")
assert extracted_user is None

274
uv.lock generated Normal file
View File

@@ -0,0 +1,274 @@
version = 1
revision = 1
requires-python = ">=3.12"
[[package]]
name = "black"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "mypy-extensions" },
{ name = "packaging" },
{ name = "pathspec" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988 },
{ url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985 },
{ url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816 },
{ url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860 },
{ url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673 },
{ url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190 },
{ url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926 },
{ url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613 },
{ url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646 },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 },
]
[[package]]
name = "click"
version = "8.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215 },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ 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 = "flask"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/de/e47735752347f4128bcf354e0da07ef311a78244eba9e3dc1d4a5ab21a98/flask-3.1.1.tar.gz", hash = "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e", size = 753440 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305 },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 },
{ url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 },
{ url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 },
{ url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 },
{ url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 },
{ url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 },
{ url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 },
{ url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 },
{ url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 },
{ url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 },
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 },
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 },
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 },
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 },
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 },
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 },
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 },
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 },
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 },
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 },
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 },
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 },
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 },
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 },
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 },
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 },
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 },
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 },
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 },
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 },
]
[[package]]
name = "pathspec"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 },
]
[[package]]
name = "platformdirs"
version = "4.3.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567 },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
]
[[package]]
name = "pytest"
version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474 },
]
[[package]]
name = "ruff"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/97/38/796a101608a90494440856ccfb52b1edae90de0b817e76bfade66b12d320/ruff-0.12.1.tar.gz", hash = "sha256:806bbc17f1104fd57451a98a58df35388ee3ab422e029e8f5cf30aa4af2c138c", size = 4413426 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/bf/3dba52c1d12ab5e78d75bd78ad52fb85a6a1f29cc447c2423037b82bed0d/ruff-0.12.1-py3-none-linux_armv6l.whl", hash = "sha256:6013a46d865111e2edb71ad692fbb8262e6c172587a57c0669332a449384a36b", size = 10305649 },
{ url = "https://files.pythonhosted.org/packages/8c/65/dab1ba90269bc8c81ce1d499a6517e28fe6f87b2119ec449257d0983cceb/ruff-0.12.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b3f75a19e03a4b0757d1412edb7f27cffb0c700365e9d6b60bc1b68d35bc89e0", size = 11120201 },
{ url = "https://files.pythonhosted.org/packages/3f/3e/2d819ffda01defe857fa2dd4cba4d19109713df4034cc36f06bbf582d62a/ruff-0.12.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9a256522893cb7e92bb1e1153283927f842dea2e48619c803243dccc8437b8be", size = 10466769 },
{ url = "https://files.pythonhosted.org/packages/63/37/bde4cf84dbd7821c8de56ec4ccc2816bce8125684f7b9e22fe4ad92364de/ruff-0.12.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:069052605fe74c765a5b4272eb89880e0ff7a31e6c0dbf8767203c1fbd31c7ff", size = 10660902 },
{ url = "https://files.pythonhosted.org/packages/0e/3a/390782a9ed1358c95e78ccc745eed1a9d657a537e5c4c4812fce06c8d1a0/ruff-0.12.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a684f125a4fec2d5a6501a466be3841113ba6847827be4573fddf8308b83477d", size = 10167002 },
{ url = "https://files.pythonhosted.org/packages/6d/05/f2d4c965009634830e97ffe733201ec59e4addc5b1c0efa035645baa9e5f/ruff-0.12.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdecdef753bf1e95797593007569d8e1697a54fca843d78f6862f7dc279e23bd", size = 11751522 },
{ url = "https://files.pythonhosted.org/packages/35/4e/4bfc519b5fcd462233f82fc20ef8b1e5ecce476c283b355af92c0935d5d9/ruff-0.12.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:70d52a058c0e7b88b602f575d23596e89bd7d8196437a4148381a3f73fcd5010", size = 12520264 },
{ url = "https://files.pythonhosted.org/packages/85/b2/7756a6925da236b3a31f234b4167397c3e5f91edb861028a631546bad719/ruff-0.12.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84d0a69d1e8d716dfeab22d8d5e7c786b73f2106429a933cee51d7b09f861d4e", size = 12133882 },
{ url = "https://files.pythonhosted.org/packages/dd/00/40da9c66d4a4d51291e619be6757fa65c91b92456ff4f01101593f3a1170/ruff-0.12.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cc32e863adcf9e71690248607ccdf25252eeeab5193768e6873b901fd441fed", size = 11608941 },
{ url = "https://files.pythonhosted.org/packages/91/e7/f898391cc026a77fbe68dfea5940f8213622474cb848eb30215538a2dadf/ruff-0.12.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fd49a4619f90d5afc65cf42e07b6ae98bb454fd5029d03b306bd9e2273d44cc", size = 11602887 },
{ url = "https://files.pythonhosted.org/packages/f6/02/0891872fc6aab8678084f4cf8826f85c5d2d24aa9114092139a38123f94b/ruff-0.12.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ed5af6aaaea20710e77698e2055b9ff9b3494891e1b24d26c07055459bb717e9", size = 10521742 },
{ url = "https://files.pythonhosted.org/packages/2a/98/d6534322c74a7d47b0f33b036b2498ccac99d8d8c40edadb552c038cecf1/ruff-0.12.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:801d626de15e6bf988fbe7ce59b303a914ff9c616d5866f8c79eb5012720ae13", size = 10149909 },
{ url = "https://files.pythonhosted.org/packages/34/5c/9b7ba8c19a31e2b6bd5e31aa1e65b533208a30512f118805371dbbbdf6a9/ruff-0.12.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2be9d32a147f98a1972c1e4df9a6956d612ca5f5578536814372113d09a27a6c", size = 11136005 },
{ url = "https://files.pythonhosted.org/packages/dc/34/9bbefa4d0ff2c000e4e533f591499f6b834346025e11da97f4ded21cb23e/ruff-0.12.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:49b7ce354eed2a322fbaea80168c902de9504e6e174fd501e9447cad0232f9e6", size = 11648579 },
{ url = "https://files.pythonhosted.org/packages/6f/1c/20cdb593783f8f411839ce749ec9ae9e4298c2b2079b40295c3e6e2089e1/ruff-0.12.1-py3-none-win32.whl", hash = "sha256:d973fa626d4c8267848755bd0414211a456e99e125dcab147f24daa9e991a245", size = 10519495 },
{ url = "https://files.pythonhosted.org/packages/cf/56/7158bd8d3cf16394928f47c637d39a7d532268cd45220bdb6cd622985760/ruff-0.12.1-py3-none-win_amd64.whl", hash = "sha256:9e1123b1c033f77bd2590e4c1fe7e8ea72ef990a85d2484351d408224d603013", size = 11547485 },
{ url = "https://files.pythonhosted.org/packages/91/d0/6902c0d017259439d6fd2fd9393cea1cfe30169940118b007d5e0ea7e954/ruff-0.12.1-py3-none-win_arm64.whl", hash = "sha256:78ad09a022c64c13cc6077707f036bab0fac8cd7088772dcd1e5be21c5002efc", size = 10691209 },
]
[[package]]
name = "sdb-backend"
version = "2.0.0"
source = { virtual = "." }
dependencies = [
{ name = "flask" },
]
[package.dev-dependencies]
dev = [
{ name = "black" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [{ name = "flask", specifier = "==3.1.1" }]
[package.metadata.requires-dev]
dev = [
{ name = "black", specifier = "==25.1.0" },
{ name = "pytest", specifier = "==8.4.1" },
{ name = "ruff", specifier = "==0.12.1" },
]
[[package]]
name = "werkzeug"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 },
]