diff --git a/.env.example b/.env.example
index 540523f3..61080941 100644
--- a/.env.example
+++ b/.env.example
@@ -8,12 +8,104 @@ REDIS_URI="redis://localhost:6379"
REDIS_URI_DEV="redis://localhost:6379"
REDIS_TTL_SECONDS=3600 # 1 hour
-# Flash configs
-SECRET_KEY=""
+# Sentry configuration (error tracking & performance monitoring)
+SENTRY_DSN="" # Leave empty to disable Sentry
+SENTRY_SEND_PII="false" # Send user emails/IPs (consider GDPR implications)
+SENTRY_TRACES_SAMPLE_RATE=0.1 # % of transactions to capture (1.0 in dev, 0.05-0.1 in prod to reduce costs)
+SENTRY_PROFILE_SAMPLE_RATE=0.05 # % of profiling sessions to capture (1.0 in dev, 0.01-0.05 in prod, very expensive)
+
+# Flask configs
+FLASK_SECRET_KEY="" # To generate: import os; print(os.urandom(32))
HOST_URI="127.0.0.1:8000"
SHORTEN_API_RATE_LIMIT_PER_HOUR=100
+ENV="development" # change to "production" in production
+
+# Logging Configuration
+LOG_LEVEL=DEBUG # DEBUG, INFO, WARNING, ERROR, CRITICAL
+LOG_FORMAT=console # json (prod) or console (dev)
+
+# Sampling Rates (0.0 to 1.0)
+SAMPLE_RATE_REDIRECT=0.05 # 5% of URL redirects
+SAMPLE_RATE_STATS=0.20 # 20% of stats queries
+SAMPLE_RATE_CACHE=0.01 # 1% of cache operations
+SAMPLE_RATE_EXPORT=0.80 # 80% of exports
# Configs for the contact and report forms
CONTACT_WEBHOOK=""
URL_REPORT_WEBHOOK=""
-HCAPTCHA_SECRET=""
\ No newline at end of file
+HCAPTCHA_SECRET=""
+
+# JWT configs
+JWT_ISSUER=
+JWT_AUDIENCE=
+ACCESS_TOKEN_TTL_SECONDS=3600
+REFRESH_TOKEN_TTL_SECONDS=2592000
+COOKIE_SECURE="false" # false: for local dev, true: for production
+JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n.....\n-----END PRIVATE KEY-----"
+JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n.....\n-----END PUBLIC KEY-----"
+JWT_SECRET=""
+
+# To generate these private key run this commands:
+# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt_private_key.pem
+
+# for public key:
+# openssl rsa -pubout -in jwt_private_key.pem -out jwt_public_key.pem
+
+# for JWT_SECRET (if not using RSA keys):
+# import os; print(os.urandom(32).hex())
+# Use JWT_SECRET only if you are not using RSA keys (JWT_PRIVATE_KEY and JWT_PUBLIC_KEY)
+
+# OAuth configs
+
+# Google OAuth
+# How to generate these keys:
+# 1. Go to https://console.cloud.google.com/apis/credentials
+# 2. Create a new project (if you don't have one)
+# 3. Enable the Google+ API
+# 4. Configure OAuth consent screen (make sure to add http://localhost:8000 and http://127.0.0.1:8000)
+# 5. Create OAuth 2.0 Client IDs and get the client ID and client secret
+# 6. Set the authorized redirect URIs / Redirect URIs to:
+# http://localhost:8000/oauth/google/callback (for local dev)
+# http://127.0.0.1:8000/oauth/google/callback (for local dev)
+# http://your-production-domain.com/oauth/google/callback (for production)
+GOOGLE_OAUTH_CLIENT_ID=""
+GOOGLE_OAUTH_CLIENT_SECRET=""
+GOOGLE_OAUTH_REDIRECT_URI="http://127.0.0.1:8000/oauth/google/callback"
+
+# GitHub OAuth
+# How to generate these keys:
+# 1. Go to https://github.com/settings/developers
+# 2. Create a new OAuth App (Not GitHub App !important)
+# 3. Set the homepage URL to http://127.0.0.1:8000 or http://localhost:8000 for local dev
+# 4. Set the authorization callback URI to http://127.0.0.1:8000/oauth/github/callback
+# For production, set it to http://your-production-domain.com/oauth/github/callback
+# 5. Make sure, where this app can be installed, "Any account" is selected
+# 6. click on "Generate a new Client Secret" to get the client secret
+GITHUB_OAUTH_CLIENT_ID=""
+GITHUB_OAUTH_CLIENT_SECRET=""
+GITHUB_OAUTH_REDIRECT_URI="http://127.0.0.1:8000/oauth/github/callback"
+
+
+# Discord OAuth
+# How to generate these keys:
+# 1. Go to https://discord.com/developers/applications
+# 2. Create a new application
+# 3. Go to OAuth2 section and add a redirect URI:
+# http://localhost:8000/oauth/discord/callback (for local dev)
+# http://your-production-domain.com/oauth/discord/callback (for production)
+# 4. In the scopes section, select "identify" and "email"
+DISCORD_OAUTH_CLIENT_ID=""
+DISCORD_OAUTH_CLIENT_SECRET=""
+DISCORD_OAUTH_REDIRECT_URI="http://127.0.0.1:8000/oauth/discord/callback"
+
+# ZeptoMail Configuration (for transactional emails)
+# How to get these credentials:
+# 1. Sign up at https://www.zoho.com/zeptomail/
+# 2. Add and verify your domain (DKIM + CNAME records)
+# 3. Create a Mail Agent
+# 4. Go to SMTP/API section and copy your Send Mail Token
+# 5. Set ZEPTO_API_TOKEN to the token value
+ZEPTO_API_TOKEN=""
+ZEPTO_FROM_EMAIL="noreply@your-domain.com"
+ZEPTO_FROM_NAME="Your App Name"
+APP_URL="http://127.0.0.1:8000"
\ No newline at end of file
diff --git a/.github/workflows/api_test.yaml b/.github/workflows/api_test.yaml
index 1267a748..9b2d6905 100644
--- a/.github/workflows/api_test.yaml
+++ b/.github/workflows/api_test.yaml
@@ -23,10 +23,10 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: 3.12
diff --git a/.github/workflows/github-ci.yaml b/.github/workflows/github-ci.yaml
index 7b0d190a..203e51bf 100644
--- a/.github/workflows/github-ci.yaml
+++ b/.github/workflows/github-ci.yaml
@@ -3,7 +3,7 @@ name: Check Code Formatting
on:
push:
branches:
- - main
+ - '*'
paths-ignore:
- '**/*.md'
- '**/*.rst'
@@ -21,10 +21,10 @@ jobs:
steps:
- name: Checkout Repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: 3.12
diff --git a/.github/workflows/minify.yaml b/.github/workflows/minify.yaml
index f36e2e3e..2a371603 100644
--- a/.github/workflows/minify.yaml
+++ b/.github/workflows/minify.yaml
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: check out the repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: replace js and css files with minified ones
uses: nizarmah/auto-minify@v3
diff --git a/.gitignore b/.gitignore
index 3bde78bb..12891ba0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -129,6 +129,7 @@ celerybeat.pid
# Environments
.env
+.env.staging
.venv
env/
venv/
@@ -183,4 +184,6 @@ todo.md
# Local API Testings
k6-tests/
-local_test_db/
\ No newline at end of file
+local_test_db/
+
+.DS_Store
\ No newline at end of file
diff --git a/README.md b/README.md
index 3ff61ea8..3b4bd4e2 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@
# ⚡ Introduction
-**spoo.me** is a free, open-source service for shortening URLs. It offers URL statistics, a free API, and customization options. You can create custom `slugs`, add `password protection`, and manage `link lifespans`.
+**spoo.me** is a free, open-source service for shortening URLs. It offers comprehensive URL statistics, a free API, and extensive customization options. You can create and manage your URLs, generate API keys, create custom `slugs`, add `password protection`, and manage `link lifespans`.
# 🔥 Features
@@ -29,13 +29,15 @@
- `Emoji Slugs` - Use emojis as slugs for your URLs 😃
- `Password Protection` - Protect your URLs with a password 🔒
- `Link Max Clicks` - Set a maximum number of clicks for your URLs 📈
-- `URL Statistics` - View detailed statistics for your URLs 📊
+- `URL Statistics` - View detailed statistics for your URLs with advanced analytics 📊
- `BOT Tracking` - Track bot clicks on your URLs 🤖
- `API` - A free and open-sourced API for URL shortening and statistics 🛠️
- `Export Click Data` - Export click data as a CSV, JSON, XLSX, or XML file 📤
+- `Dashboard` - Manage all your URLs and view analytics in one place 📱
+- `API Keys` - Generate API keys for programmatic access with rate limiting 🔑
- `Open Source` - spoo.me is open-sourced and free to use 📖
- `Absolutely Free` - No hidden costs, no premium plans, no limitations 💸
-- `No Registration` - No need to register an account to use spoo.me 📝
+- `No Registration Required` - Create short URLs without an account 📝
- `Self Hosting` - You can host spoo.me on your own server 🏠
# 📌 Endpoints
@@ -107,11 +109,30 @@ mv .env.example .env
MONGODB_URI=
CONTACT_WEBHOOK=
URL_REPORT_WEBHOOK=
+
+# OAuth Configuration (Optional - for social login features)
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+GITHUB_CLIENT_ID=
+GITHUB_CLIENT_SECRET=
+DISCORD_CLIENT_ID=
+DISCORD_CLIENT_SECRET=
+
+# JWT Secret Keys (Required for authentication)
+JWT_SECRET_KEY=
+JWT_REFRESH_SECRET_KEY=
+
+# Email Configuration (Optional - for email verification)
+ZOHO_MAIL_USERNAME=
+ZOHO_MAIL_PASSWORD=
```
> [!NOTE]
-> With this method, you can either use a cloud service like [MongoDB Atlas](https://www.mongodb.com/cloud/atlas) to store the data remotely or you can use a local MongoDB instance.
-> If you want to use a local MongoDB instance, your MongoDB URI would be `mongodb://localhost:27017/`.
+> - OAuth credentials are optional. Users can still register with email/password if OAuth is not configured.
+> - JWT secret keys should be long, random strings. You can generate them using `openssl rand -hex 32`.
+> - Email configuration is optional but recommended for email verification features.
+> - With this method, you can either use a cloud service like [MongoDB Atlas](https://www.mongodb.com/cloud/atlas) to store the data remotely or you can use a local MongoDB instance.
+> - If you want to use a local MongoDB instance, your MongoDB URI would be `mongodb://localhost:27017/`.
### 🚀 Starting the server
@@ -165,10 +186,29 @@ mv .env.example .env
MONGODB_URI=
CONTACT_WEBHOOK=
URL_REPORT_WEBHOOK=
+
+# OAuth Configuration (Optional - for social login features)
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+GITHUB_CLIENT_ID=
+GITHUB_CLIENT_SECRET=
+DISCORD_CLIENT_ID=
+DISCORD_CLIENT_SECRET=
+
+# JWT Secret Keys (Required for authentication)
+JWT_SECRET_KEY=
+JWT_REFRESH_SECRET_KEY=
+
+# Email Configuration (Optional - for email verification)
+ZOHO_MAIL_USERNAME=
+ZOHO_MAIL_PASSWORD=
```
> [!NOTE]
-> If you installed MongoDB locally, your MongoDB URI would be `mongodb://localhost:27017/` or if you are using MongoDB Atlas, you can find your MongoDB URI in the **Connect** tab of your cluster.
+> - OAuth credentials are optional. Users can still register with email/password if OAuth is not configured.
+> - JWT secret keys should be long, random strings. You can generate them using `openssl rand -hex 32`.
+> - Email configuration is optional but recommended for email verification features.
+> - If you installed MongoDB locally, your MongoDB URI would be `mongodb://localhost:27017/` or if you are using MongoDB Atlas, you can find your MongoDB URI in the **Connect** tab of your cluster.
### 🚀 Starting the server
@@ -226,7 +266,7 @@ Open your browser and go to `http://localhost:8000` to access the **spoo.me** UR
-© spoo.me . 2024
+© spoo.me . 2025
All Rights Reserved
diff --git a/api/__init__.py b/api/__init__.py
new file mode 100644
index 00000000..9350b88a
--- /dev/null
+++ b/api/__init__.py
@@ -0,0 +1,2 @@
+# Namespace for API versions
+
diff --git a/api/v1/__init__.py b/api/v1/__init__.py
new file mode 100644
index 00000000..ad4a0436
--- /dev/null
+++ b/api/v1/__init__.py
@@ -0,0 +1,13 @@
+from flask import Blueprint
+
+
+api_v1 = Blueprint("api_v1", __name__, url_prefix="/api/v1")
+
+
+# Import endpoints to register their routes on api_v1
+from . import shorten # noqa: E402,F401
+from . import keys # noqa: E402,F401
+from . import urls # noqa: E402,F401
+from . import management # noqa: E402,F401
+from . import stats # noqa: E402,F401
+from . import exports # noqa: E402,F401
diff --git a/api/v1/exports.py b/api/v1/exports.py
new file mode 100644
index 00000000..785d79a7
--- /dev/null
+++ b/api/v1/exports.py
@@ -0,0 +1,208 @@
+from flask import request, Response
+
+from blueprints.limiter import (
+ limiter,
+ dynamic_limit_for_request,
+ rate_limit_key_for_request,
+)
+from utils.auth_utils import resolve_owner_id_from_request
+from builders import ExportBuilder
+
+from . import api_v1
+
+
+@api_v1.route("/export", methods=["GET"])
+@limiter.limit(
+ lambda: dynamic_limit_for_request(
+ authenticated="30 per minute; 1000 per day",
+ anonymous="10 per minute; 200 per day",
+ ),
+ key_func=rate_limit_key_for_request,
+)
+def export_v1() -> tuple[Response, int]:
+ """
+ Export URL click statistics in various formats (CSV, XLSX, JSON, XML).
+
+ This endpoint provides data export functionality with the same powerful filtering,
+ grouping, and aggregation capabilities as the stats API, but returns formatted
+ downloadable files instead of JSON responses.
+
+ ## Authentication & Authorization
+ - **JWT Token**: Use `Authorization: Bearer ` header
+ - **API Key**: Use `Authorization: Bearer spoo_` header
+ - **Required Scopes**: `stats:read`, `urls:read`, or `admin:all`
+ - **Rate Limits**: 30/min (auth), 10/min (anon)
+
+ ## Query Parameters
+
+ ### Required
+ - **format** (string): Export file format
+ - `"csv"` - Zipped CSV files (one per data category)
+ - `"xlsx"` - Excel workbook with multiple sheets
+ - `"json"` - JSON file with complete statistics
+ - `"xml"` - XML file with complete statistics
+ - **scope** (string): Statistics scope
+ - `"all"` - All URLs owned by authenticated user (requires authentication)
+ - `"anon"` - Anonymous access to single URL (requires `short_code` + public stats)
+
+ ### Conditional
+ - **short_code** (string): URL alias (required for `scope=anon`)
+ - Cannot use `short_code` filter when `scope=anon` (security measure)
+
+ ### Optional - Time Range
+ - **start_date** (string): ISO 8601 datetime or Unix timestamp
+ - Default: 7 days before `end_date` (or 7 days ago if no `end_date`)
+ - Future dates capped to current time
+ - Examples: "2024-01-01T00:00:00Z" or 1704067200
+ - **end_date** (string): ISO 8601 datetime or Unix timestamp
+ - Default: Current time
+ - Future dates capped to current time
+ - Examples: "2024-12-31T23:59:59Z" or 1735689599
+ - **timezone** (string): IANA timezone for output formatting (default: "UTC")
+ - Converts all timestamps in response to specified timezone
+ - Examples: "America/New_York", "Europe/London", "Asia/Kolkata"
+ - Supports timezone aliases (e.g., "US/Eastern" → "America/New_York")
+ - Invalid timezones fallback to "UTC"
+
+ ### Optional - Grouping & Metrics
+ - **group_by** (string): Comma-separated dimensions (default: "time")
+ - Available: `time`, `browser`, `os`, `country`, `city`, `referrer`, `short_code`
+ - Note: `device` dimension is currently disabled (reliable detection not available)
+ - Example: `?group_by=time,country,browser`
+ - **metrics** (string): Comma-separated metrics (default: "clicks,unique_clicks")
+ - Available: `clicks`, `unique_clicks`
+ - Example: `?metrics=clicks` or `?metrics=clicks,unique_clicks`
+
+ ### Optional - Filtering
+ You can filter by dimensions in two ways (both can be combined):
+
+ #### Method 1: JSON filters parameter
+ - **filters** (JSON string): Structured dimension filters
+ - Format: `{"dimension": ["value1", "value2"]}`
+ - Available dimensions: `browser`, `os`, `country`, `city`, `referrer`, `short_code`
+ - Note: `device` filter disabled (reliable detection not available)
+ - Example: `?filters={"browser":["Chrome","Firefox"],"country":["US","CA"]}`
+
+ #### Method 2: Individual filter parameters
+ - **browser** (string): Comma-separated browser names
+ - **os** (string): Comma-separated OS names
+ - **country** (string): Comma-separated country codes
+ - **city** (string): Comma-separated city names
+ - **referrer** (string): Comma-separated referrer URLs
+ - **short_code** (string): Comma-separated URL aliases (not allowed with `scope=anon`)
+ - Example: `?browser=Chrome,Firefox&country=US,CA`
+
+ ## Response Format
+
+ Returns a downloadable file in the requested format with appropriate MIME type:
+ - **CSV**: `application/zip` (contains multiple CSV files)
+ - **XLSX**: `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
+ - **JSON**: `application/json`
+ - **XML**: `application/xml`
+
+ ## Export File Structure
+
+ ### CSV Export (Zipped)
+ Contains multiple CSV files:
+ - `summary.csv` - Overall statistics summary
+ - `clicks_by_{dimension}.csv` - Click counts grouped by dimension
+ - `unique_clicks_by_{dimension}.csv` - Unique click counts grouped by dimension
+
+ ### XLSX Export
+ Excel workbook with multiple sheets:
+ - `Summary` - Overall statistics summary
+ - `Clicks_by_{dimension}` - Click counts grouped by dimension
+ - `Unique_Clicks_by_{dimension}` - Unique click counts grouped by dimension
+
+ ### JSON Export
+ Complete statistics in JSON format (same structure as stats API response)
+
+ ### XML Export
+ Complete statistics converted to XML format
+
+ ## Example Use Cases
+
+ ### 1. Export all user stats as Excel (last 7 days)
+ ```
+ GET /api/v1/export?format=xlsx&scope=all
+ Authorization: Bearer
+ ```
+
+ ### 2. Export public URL stats as CSV
+ ```
+ GET /api/v1/export?format=csv&scope=anon&short_code=mylink
+ ```
+
+ ### 3. Export filtered stats grouped by country
+ ```
+ GET /api/v1/export?format=xlsx&scope=all&group_by=country,browser&browser=Chrome,Firefox
+ Authorization: Bearer
+ ```
+
+ ### 4. Export custom date range as JSON
+ ```
+ GET /api/v1/export?format=json&scope=all&start_date=2024-01-01&end_date=2024-12-31
+ Authorization: Bearer
+ ```
+
+ ### 5. Export specific URLs with timezone
+ ```
+ GET /api/v1/export?format=xlsx&scope=all&filters={"short_code":["link1","link2"]}&timezone=America/New_York
+ Authorization: Bearer
+ ```
+
+ ## Error Responses
+ - **400**: Invalid parameters
+ - Missing required `format` parameter
+ - Invalid format value (must be csv, xlsx, json, or xml)
+ - Invalid scope value
+ - Missing `short_code` when `scope=anon`
+ - Using `short_code` filter with `scope=anon` (security restriction)
+ - Invalid `group_by` dimensions
+ - Invalid `metrics` values
+ - Invalid JSON in `filters` parameter
+ - Invalid timezone (falls back to UTC with warning)
+ - `start_date` after `end_date`
+ - **401**: Authentication required
+ - Using `scope=all` without authentication
+ - Accessing private stats without authentication
+ - **403**: Insufficient permissions
+ - API key missing required `stats:read` scope
+ - Accessing private stats when not the owner
+ - **404**: URL/short_code not found
+ - Invalid `short_code` in `scope=anon`
+ - **429**: Rate limit exceeded
+ - 30/min for authenticated users
+ - 10/min for anonymous users
+ - **500**: Database/server error, export generation failed
+
+ ## Important Notes
+
+ ### Security
+ - **Private Stats**: URLs with `private_stats: true` require authentication and ownership
+ - **Scope Isolation**: `scope=anon` prevents `short_code` filtering to prevent privacy bypass
+ - **Rate Limits**: Lower limits than stats API due to resource-intensive export generation
+
+ ### File Sizes
+ - Large exports may take time to generate
+ - Consider using filters to reduce data volume for better performance
+ - CSV format (zipped) is most efficient for large datasets
+
+ ### Time Handling
+ - **Defaults**: 7-day window ending now if dates not specified
+ - **Future Dates**: Automatically capped to current time
+ - **Timezone Conversion**: All output timestamps converted to specified timezone
+ - **Time Bucketing**: Automatic strategy selection based on date range
+
+ Returns:
+ tuple[Response, int]: Downloadable file in requested format and HTTP status code
+ """
+ owner_id = resolve_owner_id_from_request()
+
+ builder: ExportBuilder = (
+ ExportBuilder(owner_id, request.args)
+ .parse_format()
+ .parse_stats() # Reuses StatsQueryBuilder internally
+ .build_export()
+ )
+ return builder.send()
diff --git a/api/v1/keys.py b/api/v1/keys.py
new file mode 100644
index 00000000..acb897cb
--- /dev/null
+++ b/api/v1/keys.py
@@ -0,0 +1,470 @@
+from flask import request, jsonify, g
+from datetime import datetime, timezone
+import secrets
+import hashlib
+from typing import Optional
+
+from utils.auth_utils import requires_auth
+from utils.logger import get_logger
+from bson import ObjectId
+from utils.mongo_utils import (
+ insert_api_key,
+ list_api_keys_by_user,
+ revoke_api_key_by_id,
+)
+from blueprints.limiter import limiter, rate_limit_key_for_request
+
+from . import api_v1
+
+log = get_logger(__name__)
+
+
+ALLOWED_SCOPES = {
+ "shorten:create",
+ "urls:manage",
+ "urls:read",
+ "stats:read",
+ "admin:all",
+}
+
+
+def _parse_expires_at(value: Optional[str | int | float]):
+ if value is None:
+ return None
+ try:
+ if isinstance(value, (int, float)):
+ return datetime.fromtimestamp(int(value), tz=timezone.utc)
+ raw = str(value)
+ if raw.endswith("Z"):
+ raw = raw[:-1] + "+00:00"
+ dt = datetime.fromisoformat(raw)
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt.astimezone(timezone.utc)
+ except Exception:
+ return None
+
+
+@api_v1.route("/keys", methods=["POST"])
+@limiter.limit("5 per hour", key_func=rate_limit_key_for_request)
+@requires_auth
+def create_api_key():
+ """
+ Create a new API key for programmatic access.
+
+ This endpoint allows authenticated users to generate API keys for accessing the
+ spoo.me API programmatically. API keys can be scoped with specific permissions
+ and optionally set to expire after a certain time.
+
+ ## Authentication & Authorization
+ - **JWT Token** (required): Use `Authorization: Bearer ` header
+ - **Rate Limits**: 5 per hour (to prevent abuse)
+ - **Limit**: Maximum 20 active keys per user
+
+ ## Request Body (JSON)
+
+ ### Required
+ - **name** (string): Human-readable name for the key
+ - Cannot be empty or whitespace-only
+ - Used to identify the key in lists
+ - **scopes** (array of strings): Permissions granted to this key
+ - Must be a non-empty array
+ - Available scopes:
+ - `shorten:create` - Create new shortened URLs
+ - `urls:manage` - Update and delete URLs
+ - `urls:read` - List and view URL details
+ - `stats:read` - Access analytics and statistics
+ - `admin:all` - Full administrative access
+
+ ### Optional
+ - **description** (string): Detailed description of the key's purpose
+ - Can be null or empty
+ - **expires_at** (string | integer): When the key should expire
+ - ISO 8601 datetime string or Unix epoch seconds
+ - Must be in the future
+ - Set to null for no expiration
+
+ ## Example Request
+ ```json
+ {
+ "name": "Production API Key",
+ "description": "API key for production deployment",
+ "scopes": ["shorten:create", "urls:read", "stats:read"],
+ "expires_at": "2025-12-31T23:59:59Z"
+ }
+ ```
+
+ ## Response Format
+ ```json
+ {
+ "id": "507f1f77bcf86cd799439011",
+ "name": "Production API Key",
+ "description": "API key for production deployment",
+ "scopes": ["shorten:create", "urls:read", "stats:read"],
+ "created_at": 1704067200,
+ "expires_at": 1735689599,
+ "revoked": false,
+ "token_prefix": "AbCdEfGh",
+ "token": "spoo_AbCdEfGhIjKlMnOpQrStUvWxYzAbCdEfGhIjKlMn"
+ }
+ ```
+
+ ## Response Fields
+ - **token**: The full API key (shown ONLY once at creation)
+ - Format: `spoo_`
+ - Store securely - cannot be retrieved later
+ - Use in `Authorization: Bearer ` header
+ - **token_prefix**: First 8 characters of the key (for identification)
+ - **id**: Unique identifier for this key
+ - **created_at**: Unix timestamp of creation
+ - **expires_at**: Unix timestamp of expiration (null if none)
+ - **revoked**: Whether the key has been revoked
+
+ ## Error Responses
+ - **400**: Missing/invalid name, empty/invalid scopes, invalid expiration date
+ - **400**: Maximum 20 active keys reached
+ - **401**: Authentication required, invalid JWT token
+ - **403**: Email verification required
+ - **429**: Rate limit exceeded (5 per hour)
+ - **500**: Failed to create API key, database error
+
+ ## Important Notes
+ - **One-time display**: The full token is shown ONLY at creation
+ - **Store securely**: Save the token immediately - it cannot be retrieved later
+ - **Token format**: Always starts with `spoo_` prefix
+ - **Security**: Tokens are hashed (SHA-256) before storage
+ - **Key limit**: Users can have maximum 20 active (non-revoked) keys
+
+ Returns:
+ tuple[Response, int]: JSON response with API key data and HTTP status code (201 on success)
+ """
+ # Check email verification for API key creation
+ if not g.jwt_claims.get("email_verified", False):
+ log.warning(
+ "api_key_creation_blocked",
+ reason="email_not_verified",
+ user_id=str(g.user_id),
+ )
+ return (
+ jsonify(
+ {
+ "error": "Email verification required",
+ "code": "EMAIL_NOT_VERIFIED",
+ "message": "You must verify your email address before creating API keys. Check your inbox for the verification code.",
+ }
+ ),
+ 403,
+ )
+
+ body = request.get_json(silent=True) or {}
+ name = str(body.get("name") or "").strip()
+ description = str(body.get("description") or "").strip() or None
+ scopes = body.get("scopes") or []
+ expires_at_raw = body.get("expires_at")
+
+ if not name:
+ return jsonify({"error": "name is required"}), 400
+ if not isinstance(scopes, list) or not scopes:
+ return jsonify({"error": "scopes must be a non-empty array"}), 400
+ if any(scope not in ALLOWED_SCOPES for scope in scopes):
+ return jsonify({"error": "invalid scope requested"}), 400
+
+ # Check if user has too many active keys (prevent abuse)
+ existing_keys = list_api_keys_by_user(g.user_id, projection={"revoked": 1})
+ active_keys = [k for k in existing_keys if not k.get("revoked", False)]
+
+ MAX_ACTIVE_KEYS = 20
+ if len(active_keys) >= MAX_ACTIVE_KEYS:
+ return jsonify({"error": f"maximum {MAX_ACTIVE_KEYS} active keys allowed"}), 400
+
+ expires_at = _parse_expires_at(expires_at_raw)
+ if expires_at_raw is not None and expires_at is None:
+ return jsonify({"error": "expires_at must be ISO8601 or epoch seconds"}), 400
+ if expires_at and expires_at <= datetime.now(timezone.utc):
+ return jsonify({"error": "expires_at must be in the future"}), 400
+
+ # Generate key
+ raw = secrets.token_urlsafe(32)
+ token_prefix = raw[:8]
+ token_hash = hashlib.sha256(raw.encode("utf-8")).hexdigest()
+
+ user_oid = ObjectId(g.user_id) if not isinstance(g.user_id, ObjectId) else g.user_id
+ doc = {
+ "user_id": user_oid,
+ "token_prefix": token_prefix,
+ "token_hash": token_hash,
+ "name": name,
+ "description": description,
+ "scopes": scopes,
+ "expires_at": expires_at,
+ "created_at": datetime.now(timezone.utc),
+ "revoked": False,
+ }
+
+ key_id = insert_api_key(doc)
+ if not key_id:
+ log.error(
+ "api_key_creation_failed",
+ user_id=str(g.user_id),
+ scopes=scopes,
+ error="database_error",
+ )
+ return jsonify({"error": "failed to create api key"}), 500
+
+ log.info(
+ "api_key_created",
+ user_id=str(g.user_id),
+ key_id=str(key_id),
+ key_prefix=token_prefix,
+ scopes=scopes,
+ expires_at=expires_at.isoformat() if expires_at else None,
+ )
+
+ return (
+ jsonify(
+ {
+ "id": str(key_id),
+ "name": name,
+ "description": description,
+ "scopes": scopes,
+ "created_at": int(
+ (
+ doc["created_at"].replace(tzinfo=timezone.utc)
+ if doc["created_at"] and doc["created_at"].tzinfo is None
+ else doc["created_at"]
+ ).timestamp()
+ ),
+ "expires_at": (
+ int(
+ (
+ expires_at.replace(tzinfo=timezone.utc)
+ if expires_at and expires_at.tzinfo is None
+ else expires_at
+ ).timestamp()
+ )
+ if expires_at
+ else None
+ ),
+ "revoked": False,
+ "token_prefix": token_prefix,
+ "token": f"spoo_{raw}",
+ }
+ ),
+ 201,
+ )
+
+
+@api_v1.route("/keys", methods=["GET"])
+@limiter.limit("60 per minute", key_func=rate_limit_key_for_request)
+@requires_auth
+def list_api_keys():
+ """
+ List all API keys for the authenticated user.
+
+ This endpoint returns all API keys (both active and revoked) created by the
+ authenticated user. For security, only the token prefix is returned, not the
+ full token value.
+
+ ## Authentication & Authorization
+ - **JWT Token** (required): Use `Authorization: Bearer ` header
+ - **Rate Limits**: 60 per minute
+
+ ## Query Parameters
+ No query parameters required.
+
+ ## Response Format
+ ```json
+ {
+ "keys": [
+ {
+ "id": "507f1f77bcf86cd799439011",
+ "name": "Production API Key",
+ "description": "API key for production deployment",
+ "scopes": ["shorten:create", "urls:read", "stats:read"],
+ "created_at": 1704067200,
+ "expires_at": 1735689599,
+ "revoked": false,
+ "token_prefix": "AbCdEfGh"
+ },
+ {
+ "id": "507f1f77bcf86cd799439012",
+ "name": "Old Testing Key",
+ "description": null,
+ "scopes": ["shorten:create"],
+ "created_at": 1700000000,
+ "expires_at": null,
+ "revoked": true,
+ "token_prefix": "XyZaBcDe"
+ }
+ ]
+ }
+ ```
+
+ ## Response Fields
+ - **keys**: Array of API key objects
+ - **id**: Unique identifier for the key
+ - **name**: Human-readable name
+ - **description**: Optional description (can be null)
+ - **scopes**: Array of permission scopes
+ - **created_at**: Unix timestamp of creation
+ - **expires_at**: Unix timestamp of expiration (null if none)
+ - **revoked**: Boolean indicating if key is revoked
+ - **token_prefix**: First 8 characters (for identification)
+
+ ## Key Status Interpretation
+ - **Active**: `revoked: false` and (`expires_at: null` or `expires_at` in future)
+ - **Revoked**: `revoked: true`
+ - **Expired**: `expires_at` in the past
+
+ ## Error Responses
+ - **401**: Authentication required, invalid JWT token
+ - **429**: Rate limit exceeded
+ - **500**: Database error
+
+ ## Important Notes
+ - **No full tokens**: For security, full tokens are never returned
+ - **All keys shown**: Both active and revoked keys are included
+ - **Token prefix**: Use to identify which key is which
+ - **Security**: Check the `revoked` and `expires_at` fields to verify key status
+
+ Returns:
+ tuple[Response, int]: JSON response with list of API keys and HTTP status code
+ """
+ keys = list_api_keys_by_user(g.user_id)
+ result = []
+ for k in keys:
+ result.append(
+ {
+ "id": str(k["_id"]),
+ "name": k.get("name"),
+ "description": k.get("description"),
+ "scopes": k.get("scopes", []),
+ "created_at": (
+ int(
+ (
+ k.get("created_at").replace(tzinfo=timezone.utc)
+ if k.get("created_at")
+ and k.get("created_at").tzinfo is None
+ else k.get("created_at")
+ ).timestamp()
+ )
+ if k.get("created_at")
+ else None
+ ),
+ "expires_at": (
+ int(
+ (
+ k.get("expires_at").replace(tzinfo=timezone.utc)
+ if k.get("expires_at")
+ and k.get("expires_at").tzinfo is None
+ else k.get("expires_at")
+ ).timestamp()
+ )
+ if k.get("expires_at")
+ else None
+ ),
+ "revoked": bool(k.get("revoked", False)),
+ "token_prefix": k.get("token_prefix"),
+ }
+ )
+ return jsonify({"keys": result})
+
+
+@api_v1.route("/keys/", methods=["DELETE"])
+@requires_auth
+def delete_api_key(key_id):
+ """
+ Delete or revoke an API key.
+
+ This endpoint allows users to remove an API key. By default, keys are permanently
+ deleted (hard delete), but you can optionally just revoke them to preserve audit logs.
+
+ ## Authentication & Authorization
+ - **JWT Token** (required): Use `Authorization: Bearer ` header
+ - **Ownership**: Can only delete/revoke your own API keys
+
+ ## URL Parameters
+ - **key_id** (string): MongoDB ObjectId of the API key
+
+ ## Query Parameters
+ - **revoke** (boolean): If "true", revoke instead of delete (default: "false")
+ - `?revoke=true` - Marks key as revoked but keeps record
+ - Default behavior - Permanently deletes the key
+
+ ## Behavior Modes
+
+ ### Hard Delete (Default)
+ ```
+ DELETE /api/v1/keys/507f1f77bcf86cd799439011
+ ```
+ - Permanently removes the key from database
+ - No audit trail preserved
+ - Key ID becomes invalid
+ - Recommended for unused/test keys
+
+ ### Soft Delete (Revoke)
+ ```
+ DELETE /api/v1/keys/507f1f77bcf86cd799439011?revoke=true
+ ```
+ - Marks key as `revoked: true`
+ - Preserves key record for audit purposes
+ - Key still appears in list but cannot be used
+ - Recommended for production keys
+
+ ## Response Format
+ ```json
+ {
+ "success": true,
+ "action": "deleted"
+ }
+ ```
+ or
+ ```json
+ {
+ "success": true,
+ "action": "revoked"
+ }
+ ```
+
+ ## Error Responses
+ - **401**: Authentication required, invalid JWT token
+ - **404**: Key not found, access denied (not your key), invalid key ID
+ - **500**: Database error
+
+ ## Important Notes
+ - **Immediate effect**: Key stops working immediately
+ - **No confirmation**: Action is performed without confirmation prompt
+ - **Irreversible**: Hard deletes cannot be undone
+ - **Ownership**: Can only delete your own keys
+ - **Revoke vs Delete**: Use revoke for audit trails, delete for cleanup
+
+ ## Use Cases
+ - **Delete**: Removing test keys, cleaning up unused keys
+ - **Revoke**: Disabling production keys while preserving history
+ - **Security**: Immediately disable compromised keys
+
+ Returns:
+ tuple[Response, int]: JSON response confirming action and HTTP status code
+ """
+ # Default to hard delete for cleaner UX, use ?revoke=true to just revoke
+ revoke_only = (request.args.get("revoke") or "false").lower() == "true"
+ ok = revoke_api_key_by_id(g.user_id, key_id, hard_delete=not revoke_only)
+ if not ok:
+ log.warning(
+ "api_key_deletion_failed",
+ user_id=str(g.user_id),
+ key_id=key_id,
+ action="revoke" if revoke_only else "delete",
+ reason="not_found_or_access_denied",
+ )
+ return jsonify({"error": "key not found or access denied"}), 404
+
+ action = "revoked" if revoke_only else "deleted"
+ log.info(
+ "api_key_revoked" if revoke_only else "api_key_deleted",
+ user_id=str(g.user_id),
+ key_id=key_id,
+ action=action,
+ )
+
+ return jsonify({"success": True, "action": action})
diff --git a/api/v1/management.py b/api/v1/management.py
new file mode 100644
index 00000000..a76b9daa
--- /dev/null
+++ b/api/v1/management.py
@@ -0,0 +1,321 @@
+from flask import request, jsonify, Response
+from bson import ObjectId
+
+from blueprints.limiter import (
+ limiter,
+ dynamic_limit_for_request,
+ rate_limit_key_for_request,
+)
+from utils.mongo_utils import urls_v2_collection
+from utils.logger import get_logger
+from builders import UpdateUrlRequestBuilder
+from cache import cache_query as cq
+
+from . import api_v1
+
+log = get_logger(__name__)
+
+
+@api_v1.route("/urls/", methods=["PATCH"])
+@limiter.limit(
+ lambda: dynamic_limit_for_request(
+ authenticated="120 per minute; 2000 per day",
+ anonymous="0 per minute", # Requires authentication
+ ),
+ key_func=rate_limit_key_for_request,
+)
+def update_url_v1(url_id: str) -> tuple[Response, int]:
+ """
+ Update an existing shortened URL's properties.
+
+ This endpoint allows authenticated users to modify properties of URLs they own,
+ including the destination, alias, password, expiration, and other settings.
+
+ ## Authentication & Authorization
+ - **JWT Token** (required): Use `Authorization: Bearer ` header
+ - **API Key** (required): Use `Authorization: Bearer spoo_` header
+ - **Required Scopes**: `urls:manage` or `admin:all`
+ - **Ownership**: Can only update URLs you own
+ - **Rate Limits**: 120/min & 2000/day (auth), Anonymous: Disabled
+
+ ## URL Parameters
+ - **url_id** (string): MongoDB ObjectId of the URL to update
+
+ ## Request Body (JSON)
+ All fields are optional - only include fields you want to update.
+
+ ### Optional Fields
+ - **long_url** (string): New destination URL
+ - Must start with http:// or https://
+ - Maximum length: 2048 characters
+ - **alias** (string): New custom short code
+ - 16 characters max, auto truncated if longer
+ - Alphanumeric, hyphens, and underscores only
+ - Must be unique (returns 409 if taken)
+ - Cannot change to existing alias
+ - **password** (string | null): Update or remove password
+ - Atleast 8 characters long, must contain a letter and a number and a special character either '@' or '.' and cannot be consecutive
+ - Set to `null` or empty string to remove
+ - **max_clicks** (integer | null): Update or remove click limit
+ - Must be positive integer to set
+ - Set to `null` to remove limit
+ - **expire_after** (integer | null): Update or remove expiration
+ - Unix epoch seconds (must be in future)
+ - Set to `null` to remove expiration
+ - **block_bots** (boolean | null): Update or remove bot blocking
+ - Set to `null` to remove
+ - **private_stats** (boolean | null): Update or remove stats privacy
+ - Set to `null` to remove
+ - **status** (string): Change URL status
+ - Values: "ACTIVE" or "INACTIVE"
+
+ ## Example Request
+ ```json
+ {
+ "long_url": "https://example.com/new-destination",
+ "max_clicks": 500,
+ "password": null,
+ "private_stats": true
+ }
+ ```
+
+ ## Response Format
+ ```json
+ {
+ "id": "507f1f77bcf86cd799439011",
+ "alias": "mylink",
+ "long_url": "https://example.com/new-destination",
+ "status": "ACTIVE",
+ "password_set": false,
+ "max_clicks": 500,
+ "expire_after": null,
+ "block_bots": false,
+ "private_stats": true,
+ "updated_at": 1704067200
+ }
+ ```
+
+ ## Special Responses
+ - **200**: Successfully updated (or no changes detected)
+ - **400**: Invalid URL ID format, invalid field values
+ - **401**: Authentication required, invalid token
+ - **403**: Access denied (not the owner), insufficient scope
+ - **404**: URL not found
+ - **409**: New alias already taken
+ - **429**: Rate limit exceeded
+ - **500**: Database/server error
+
+ Returns:
+ tuple[Response, int]: JSON response with updated URL data and HTTP status code
+ """
+ payload = request.get_json(silent=True) or {}
+
+ builder = (
+ UpdateUrlRequestBuilder(payload, url_id)
+ .parse_auth_scope(required_scopes={"urls:manage", "admin:all"})
+ .load_and_validate_ownership()
+ .validate_long_url_if_present()
+ .validate_alias_custom()
+ .validate_password()
+ .parse_max_clicks()
+ .parse_expire_after()
+ .parse_block_bots()
+ .parse_private_stats()
+ )
+
+ return builder.build_update()
+
+
+@api_v1.route("/urls//status", methods=["PATCH"])
+@limiter.limit(
+ lambda: dynamic_limit_for_request(
+ authenticated="120 per minute; 2000 per day",
+ anonymous="0 per minute", # Requires authentication
+ ),
+ key_func=rate_limit_key_for_request,
+)
+def update_url_status_v1(url_id: str) -> tuple[Response, int]:
+ """
+ Update only the status of a shortened URL (ACTIVE/INACTIVE).
+
+ This is a convenience endpoint for quickly enabling or disabling a URL
+ without modifying any other properties. Only status changes are allowed.
+
+ ## Authentication & Authorization
+ - **JWT Token** (required): Use `Authorization: Bearer ` header
+ - **API Key** (required): Use `Authorization: Bearer spoo_` header
+ - **Required Scopes**: `urls:manage` or `admin:all`
+ - **Ownership**: Can only update URLs you own
+ - **Rate Limits**: 120/min & 2000/day (auth), Anonymous: Disabled
+
+ ## URL Parameters
+ - **url_id** (string): MongoDB ObjectId of the URL to update
+
+ ## Request Body (JSON)
+
+ ### Required
+ - **status** (string): New status for the URL
+ - Values: "ACTIVE" or "INACTIVE"
+ - "ACTIVE": URL is accessible and redirects normally
+ - "INACTIVE": URL is disabled and won't redirect
+
+ ## Example Request
+ ```json
+ {
+ "status": "INACTIVE"
+ }
+ ```
+
+ ## Response Format
+ ```json
+ {
+ "id": "507f1f77bcf86cd799439011",
+ "alias": "mylink",
+ "long_url": "https://example.com/destination",
+ "status": "INACTIVE",
+ "password_set": true,
+ "max_clicks": 100,
+ "expire_after": 1735689600,
+ "block_bots": false,
+ "private_stats": false,
+ "updated_at": 1704067200
+ }
+ ```
+
+ ## Error Responses
+ - **400**: Invalid URL ID format, invalid status value
+ - **401**: Authentication required, invalid token
+ - **403**: Access denied (not the owner), insufficient scope
+ - **404**: URL not found
+ - **429**: Rate limit exceeded
+ - **500**: Database/server error
+
+ Returns:
+ tuple[Response, int]: JSON response with updated URL data and HTTP status code
+ """
+ payload = request.get_json(silent=True) or {}
+
+ # Only allow status changes
+ filtered_payload = {"status": payload.get("status")}
+
+ builder = (
+ UpdateUrlRequestBuilder(filtered_payload, url_id)
+ .parse_auth_scope(required_scopes={"urls:manage", "admin:all"})
+ .load_and_validate_ownership()
+ .parse_status_change()
+ )
+
+ return builder.build_update()
+
+
+@api_v1.route("/urls/", methods=["DELETE"])
+@limiter.limit(
+ lambda: dynamic_limit_for_request(
+ authenticated="60 per minute; 1000 per day",
+ anonymous="0 per minute", # Requires authentication
+ ),
+ key_func=rate_limit_key_for_request,
+)
+def delete_url_v1(url_id: str) -> tuple[Response, int]:
+ """
+ Permanently delete a shortened URL from the database.
+
+ This endpoint removes a URL completely from the system. This action is
+ irreversible - all associated data including analytics will be permanently lost.
+ The short alias becomes available for reuse after deletion.
+
+ ## Authentication & Authorization
+ - **JWT Token** (required): Use `Authorization: Bearer ` header
+ - **API Key** (required): Use `Authorization: Bearer spoo_` header
+ - **Required Scopes**: `urls:manage` or `admin:all`
+ - **Ownership**: Can only delete URLs you own
+ - **Rate Limits**: 60/min & 1000/day (auth), Anonymous: Disabled
+
+ ## URL Parameters
+ - **url_id** (string): MongoDB ObjectId of the URL to delete
+
+ ## Request Body
+ No request body required.
+
+ ## Response Format
+ ```json
+ {
+ "message": "URL deleted",
+ "id": "507f1f77bcf86cd799439011"
+ }
+ ```
+
+ ## Error Responses
+ - **400**: Invalid URL ID format
+ - **401**: Authentication required, invalid token
+ - **403**: Access denied (not the owner), insufficient scope
+ - **404**: URL not found or already deleted
+ - **429**: Rate limit exceeded
+ - **500**: Database/server error
+
+ ## Important Notes
+ - **Irreversible**: Deletion cannot be undone
+ - **Data Loss**: All analytics and click data will be lost
+ - **Cache**: The system automatically invalidates the cache for the deleted URL
+ - **Alias Reuse**: The deleted alias becomes available for new URLs
+
+ ## Alternative
+ Consider using `PATCH /api/v1/urls//status` to set status to "INACTIVE"
+ instead of deleting, which preserves data while disabling the URL.
+
+ Returns:
+ tuple[Response, int]: JSON response confirming deletion and HTTP status code (200 on success)
+ """
+ try:
+ url_oid = ObjectId(url_id)
+ except Exception:
+ return jsonify({"error": "Invalid URL ID format"}), 400
+
+ # Validate ownership first
+ builder = UpdateUrlRequestBuilder({}, url_id)
+ builder.parse_auth_scope(required_scopes={"urls:manage", "admin:all"})
+ builder.load_and_validate_ownership()
+
+ if builder.error:
+ return builder.error
+
+ # Get the alias/short_code before deletion for cache invalidation
+ url_doc = builder.existing_doc
+ short_code = url_doc.get("alias") if url_doc else None
+
+ try:
+ result = urls_v2_collection.delete_one({"_id": url_oid})
+ if result.deleted_count == 0:
+ return jsonify({"error": "URL not found"}), 404
+
+ log.info(
+ "url_deleted",
+ url_id=url_id,
+ alias=short_code,
+ owner_id=str(builder.owner_id) if builder.owner_id else None,
+ )
+
+ # Invalidate cache after successful deletion
+ if short_code:
+ try:
+ cq.invalidate_url_cache(short_code=short_code)
+ except Exception as e:
+ log.error(
+ "cache_invalidation_failed",
+ short_code=short_code,
+ reason="post_deletion",
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+
+ return jsonify({"message": "URL deleted", "id": url_id}), 200
+
+ except Exception as e:
+ log.error(
+ "url_deletion_failed",
+ url_id=url_id,
+ alias=short_code,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return jsonify({"error": "Database error"}), 500
diff --git a/api/v1/shorten.py b/api/v1/shorten.py
new file mode 100644
index 00000000..3a363a3e
--- /dev/null
+++ b/api/v1/shorten.py
@@ -0,0 +1,118 @@
+from flask import request, Response
+
+from blueprints.limiter import (
+ limiter,
+ dynamic_limit_for_request,
+ rate_limit_key_for_request,
+)
+from utils.mongo_utils import urls_v2_collection
+from builders import ShortenRequestBuilder
+
+from . import api_v1
+
+
+@api_v1.route("/shorten", methods=["POST"])
+@limiter.limit(
+ lambda: dynamic_limit_for_request(
+ authenticated="60 per minute; 5000 per day",
+ anonymous="20 per minute; 1000 per day",
+ ),
+ key_func=rate_limit_key_for_request,
+)
+def shorten_v1() -> tuple[Response, int]:
+ """
+ Create a new shortened URL.
+
+ This endpoint creates a shortened URL with optional customization including
+ password protection, expiration, click limits, and bot blocking.
+
+ ## Authentication & Authorization
+ - **JWT Token** (optional): Use `Authorization: Bearer ` header
+ - **API Key** (optional): Use `Authorization: Bearer spoo_` header
+ - **Required Scopes** (if authenticated): `shorten:create` or `admin:all`
+ - **Rate Limits**: 60/min & 5000/day (auth), 20/min & 1000/day (anon)
+
+ ## Request Body (JSON)
+
+ ### Required
+ - **long_url** (string): The original URL to shorten
+ - Must start with http:// or https://
+ - Maximum length: 2048 characters
+ - Must be a valid, accessible URL
+
+ ### Optional
+ - **alias** (string): Custom short code/alias for the URL
+ - 16 characters max, auto truncated if longer
+ - Alphanumeric, hyphens, and underscores only
+ - Must be unique (returns 409 if taken)
+ - Auto-generated if not provided
+ - **password** (string): Password to protect the shortened URL
+ - Minimum 4 characters
+ - Stored as bcrypt hash
+ - Required when accessing the shortened URL
+ - **max_clicks** (integer): Maximum number of clicks allowed
+ - Must be positive integer
+ - URL becomes inactive after limit reached
+ - Set to `null` or omit for unlimited clicks
+ - **expire_after** (integer): Expiration timestamp (Unix epoch seconds)
+ - Must be in the future
+ - URL becomes inactive after this time
+ - Set to `null` or omit for no expiration
+ - **block_bots** (boolean): Block known bot user agents
+ - Default: `false`
+ - When `true`, blocks automated traffic
+ - **private_stats** (boolean): Make statistics private
+ - Default: `false`
+ - When `true`, only owner can view stats
+
+ ## Example Request
+ ```json
+ {
+ "long_url": "https://example.com/very/long/url",
+ "alias": "mylink",
+ "password": "secure123",
+ "max_clicks": 100,
+ "expire_after": 1735689600,
+ "block_bots": true,
+ "private_stats": false
+ }
+ ```
+
+ ## Response Format
+ ```json
+ {
+ "alias": "mylink",
+ "short_url": "https://spoo.me/mylink",
+ "long_url": "https://example.com/very/long/url",
+ "owner_id": "507f1f77bcf86cd799439011",
+ "created_at": 1704067200,
+ "status": "ACTIVE",
+ "private_stats": false
+ }
+ ```
+
+ ## Error Responses
+ - **400**: Invalid request body, missing required fields, invalid URL format
+ - **401**: Authentication required (if invalid token provided)
+ - **403**: Insufficient permissions (missing required scope)
+ - **409**: Alias already taken
+ - **429**: Rate limit exceeded
+ - **500**: Database/server error
+
+ Returns:
+ tuple[Response, int]: JSON response with shortened URL data and HTTP status code (201 on success)
+ """
+ payload = request.get_json(silent=True) or {}
+
+ builder = (
+ ShortenRequestBuilder(payload)
+ .parse_auth_scope(required_scopes={"shorten:create", "admin:all"})
+ .validate_long_url()
+ .validate_or_generate_alias()
+ .validate_password()
+ .parse_block_bots()
+ .parse_max_clicks()
+ .parse_expire_after()
+ .parse_private_stats()
+ )
+ return builder.build(collection=urls_v2_collection)
diff --git a/api/v1/stats.py b/api/v1/stats.py
new file mode 100644
index 00000000..fd965786
--- /dev/null
+++ b/api/v1/stats.py
@@ -0,0 +1,243 @@
+from flask import request, Response
+
+from blueprints.limiter import (
+ limiter,
+ dynamic_limit_for_request,
+ rate_limit_key_for_request,
+)
+from utils.auth_utils import resolve_owner_id_from_request
+from builders import StatsQueryBuilder
+
+from . import api_v1
+
+
+@api_v1.route("/stats", methods=["GET"])
+@limiter.limit(
+ lambda: dynamic_limit_for_request(
+ authenticated="60 per minute; 5000 per day",
+ anonymous="20 per minute; 1000 per day",
+ ),
+ key_func=rate_limit_key_for_request,
+)
+def stats_v1() -> tuple[Response, int]:
+ """
+ Get URL click statistics with flexible filtering, grouping, and aggregation.
+
+ This endpoint provides comprehensive analytics for shortened URLs with support for
+ different scopes, time ranges, dimensional grouping, and privacy controls.
+
+ ## Authentication & Authorization
+ - **JWT Token**: Use `Authorization: Bearer ` header
+ - **API Key**: Use `Authorization: Bearer spoo_` header
+ - **Required Scopes**: `stats:read`, `urls:read`, or `admin:all`
+ - **Rate Limits**: 60/min (auth), 20/min (anon)
+
+ ## Query Parameters
+
+ ### Required
+ - **scope** (string): Statistics scope
+ - `"all"` - All URLs owned by authenticated user (requires authentication)
+ - `"anon"` - Anonymous access to single URL (requires `short_code` + public stats)
+
+ ### Conditional
+ - **short_code** (string): URL alias (required for `scope=anon`)
+ - Cannot use `short_code` filter when `scope=anon` (security measure)
+
+ ### Optional - Time Range
+ - **start_date** (string): ISO 8601 datetime or Unix timestamp
+ - Default: 7 days before `end_date` (or 7 days ago if no `end_date`)
+ - Future dates capped to current time
+ - Examples: "2024-01-01T00:00:00Z" or 1704067200
+ - **end_date** (string): ISO 8601 datetime or Unix timestamp
+ - Default: Current time
+ - Future dates capped to current time
+ - Examples: "2024-12-31T23:59:59Z" or 1735689599
+ - **timezone** (string): IANA timezone for output formatting (default: "UTC")
+ - Converts all timestamps in response to specified timezone
+ - Examples: "America/New_York", "Europe/London", "Asia/Kolkata"
+ - Supports timezone aliases (e.g., "US/Eastern" → "America/New_York")
+ - Invalid timezones fallback to "UTC"
+
+ ### Optional - Grouping & Metrics
+ - **group_by** (string): Comma-separated dimensions (default: "time")
+ - Available: `time`, `browser`, `os`, `country`, `city`, `referrer`, `short_code`
+ - Note: `device` dimension is currently disabled (reliable detection not available)
+ - Example: `?group_by=time,country,browser`
+ - **metrics** (string): Comma-separated metrics (default: "clicks,unique_clicks")
+ - Available: `clicks`, `unique_clicks`
+ - Example: `?metrics=clicks` or `?metrics=clicks,unique_clicks`
+
+ ### Optional - Filtering
+ You can filter by dimensions in two ways (both can be combined):
+
+ #### Method 1: JSON filters parameter
+ - **filters** (JSON string): Structured dimension filters
+ - Format: `{"dimension": ["value1", "value2"]}`
+ - Available dimensions: `browser`, `os`, `country`, `city`, `referrer`, `short_code`
+ - Note: `device` filter disabled (reliable detection not available)
+ - Example: `?filters={"browser":["Chrome","Firefox"],"country":["US","CA"]}`
+
+ #### Method 2: Individual filter parameters
+ - **browser** (string): Comma-separated browser names
+ - **os** (string): Comma-separated OS names
+ - **country** (string): Comma-separated country codes
+ - **city** (string): Comma-separated city names
+ - **referrer** (string): Comma-separated referrer URLs
+ - **short_code** (string): Comma-separated URL aliases (not allowed with `scope=anon`)
+ - Example: `?browser=Chrome,Firefox&country=US,CA`
+
+ ## Response Format
+ ```json
+ {
+ "scope": "all",
+ "timezone": "America/New_York",
+ "group_by": ["time"],
+ "filters": {},
+ "time_range": {
+ "start_date": "2024-12-31T19:00:00-05:00",
+ "end_date": "2025-01-07T19:00:00-05:00"
+ },
+ "summary": {
+ "total_clicks": 150,
+ "unique_clicks": 89,
+ "first_click": "2025-01-01T05:30:00-05:00",
+ "last_click": "2025-01-07T13:45:00-05:00",
+ "avg_redirection_time": 142.35
+ },
+ "metrics": {
+ "clicks_by_time": [
+ {"time": "2024-12-31", "clicks": 25},
+ {"time": "2025-01-01", "clicks": 18}
+ ],
+ "unique_clicks_by_time": [
+ {"time": "2024-12-31", "unique_clicks": 20},
+ {"time": "2025-01-01", "unique_clicks": 15}
+ ]
+ },
+ "time_bucket_info": {
+ "strategy": "daily",
+ "timezone": "America/New_York"
+ }
+ }
+ ```
+
+ ## Response Fields
+ - **scope**: Echo of the requested scope (`all` or `anon`)
+ - **timezone**: IANA timezone used for formatting (all timestamps use this)
+ - **group_by**: Array of dimensions used for grouping
+ - **filters**: Applied dimension filters (empty object if none)
+ - **time_range**: Query time window
+ - **start_date**: ISO 8601 datetime in specified timezone
+ - **end_date**: ISO 8601 datetime in specified timezone
+ - **summary**: Aggregate statistics across all data
+ - **total_clicks**: Total number of clicks
+ - **unique_clicks**: Count of unique IP addresses
+ - **first_click**: Timestamp of first click (in specified timezone)
+ - **last_click**: Timestamp of last click (in specified timezone)
+ - **avg_redirection_time**: Average redirect time in milliseconds
+ - **metrics**: Grouped statistics (keys depend on `group_by` and `metrics` params)
+ - Format: `{metric}_by_{dimension}` (e.g., `clicks_by_browser`)
+ - Each entry contains dimension value and metric count
+ - **time_bucket_info**: Time bucketing details (only present if `group_by` includes `time`)
+ - **strategy**: Bucketing strategy used (`hourly`, `daily`, `weekly`, `monthly`)
+ - **timezone**: Timezone used for time buckets
+
+ ## Example Use Cases
+
+ ### 1. Get all stats for authenticated user (last 7 days)
+ ```
+ GET /api/v1/stats?scope=all
+ Authorization: Bearer
+ ```
+
+ ### 2. Anonymous access to public URL stats
+ ```
+ GET /api/v1/stats?scope=anon&short_code=mylink
+ ```
+
+ ### 3. Stats grouped by country and browser
+ ```
+ GET /api/v1/stats?scope=all&group_by=country,browser
+ Authorization: Bearer
+ ```
+
+ ### 4. Custom date range with timezone
+ ```
+ GET /api/v1/stats?scope=all&start_date=2024-01-01&end_date=2024-12-31&timezone=America/New_York
+ Authorization: Bearer
+ ```
+
+ ### 5. Filter by browser and country
+ ```
+ GET /api/v1/stats?scope=all&browser=Chrome,Firefox&country=US,CA
+ Authorization: Bearer
+ ```
+
+ ### 6. Multi-URL stats with filters
+ ```
+ GET /api/v1/stats?scope=all&filters={"short_code":["link1","link2"]}&group_by=short_code,country
+ Authorization: Bearer
+ ```
+
+ ## Error Responses
+ - **400**: Invalid parameters, missing required fields, invalid date range
+ - Invalid scope value
+ - Missing `short_code` when `scope=anon`
+ - Using `short_code` filter with `scope=anon` (security restriction)
+ - Invalid `group_by` dimensions
+ - Invalid `metrics` values
+ - Invalid JSON in `filters` parameter
+ - Invalid timezone (falls back to UTC with warning)
+ - `start_date` after `end_date`
+ - **401**: Authentication required, invalid token
+ - Using `scope=all` without authentication
+ - Accessing private stats without authentication
+ - **403**: Insufficient permissions, private statistics, access denied
+ - API key missing required `stats:read` scope
+ - Accessing private stats when not the owner
+ - **404**: URL/short_code not found, ownership validation failed
+ - Invalid `short_code` in `scope=anon`
+ - **429**: Rate limit exceeded
+ - 60/min for authenticated users
+ - 20/min for anonymous users
+ - **500**: Database/server error
+
+ ## Important Notes
+
+ ### Security
+ - **Private Stats**: URLs with `private_stats: true` require authentication and ownership
+ - **Scope Isolation**: `scope=anon` prevents `short_code` filtering to prevent privacy bypass
+ - **Rate Limits**: Higher limits for authenticated users (60/min vs 20/min)
+
+ ### Time Handling
+ - **Defaults**: 7-day window ending now if dates not specified
+ - **Future Dates**: Automatically capped to current time
+ - **Timezone Conversion**: All output timestamps converted to specified timezone
+ - **Time Bucketing**: Automatic strategy selection based on date range
+ - < 2 days: hourly buckets
+ - 2-60 days: daily buckets
+ - 60-365 days: weekly buckets
+ - > 365 days: monthly buckets
+
+ ### Filtering & Grouping
+ - **Device Dimension**: Currently disabled (reliable detection not implemented)
+ - **Multiple Dimensions**: Can group by multiple dimensions simultaneously
+ - **Filter Combination**: Both JSON and individual filters can be used together
+ - **Empty Results**: Returns zero counts, not errors, for no matching data
+
+ Returns:
+ tuple[Response, int]: JSON response with statistics data and HTTP status code
+ """
+ owner_id = resolve_owner_id_from_request()
+
+ builder: StatsQueryBuilder = (
+ StatsQueryBuilder(owner_id, request.args)
+ .parse_auth_scope()
+ .parse_scope_and_target()
+ .parse_time_range()
+ .parse_filters()
+ .parse_group_by()
+ .parse_metrics()
+ .parse_timezone()
+ )
+ return builder.build()
diff --git a/api/v1/urls.py b/api/v1/urls.py
new file mode 100644
index 00000000..1d26f246
--- /dev/null
+++ b/api/v1/urls.py
@@ -0,0 +1,157 @@
+from flask import request, jsonify, Response
+
+from blueprints.limiter import (
+ limiter,
+ dynamic_limit_for_request,
+ rate_limit_key_for_request,
+)
+from utils.auth_utils import resolve_owner_id_from_request
+from builders import UrlListQueryBuilder
+
+from . import api_v1
+
+
+@api_v1.route("/urls", methods=["GET"])
+@limiter.limit(
+ lambda: dynamic_limit_for_request(
+ authenticated="60 per minute; 5000 per day",
+ anonymous="0 per minute", # Requires authentication
+ ),
+ key_func=rate_limit_key_for_request,
+)
+def list_urls_v1() -> tuple[Response, int]:
+ """
+ List all shortened URLs owned by the authenticated user with pagination, filtering, and sorting.
+
+ This endpoint provides a comprehensive view of all URLs created by the authenticated user,
+ with support for flexible querying, pagination, multi-field filtering, and custom sorting.
+
+ ## Authentication & Authorization
+ - **JWT Token** (required): Use `Authorization: Bearer ` header
+ - **API Key** (required): Use `Authorization: Bearer spoo_` header
+ - **Required Scopes**: `urls:manage`, `urls:read`, or `admin:all`
+ - **Rate Limits**: 60/min & 5000/day (auth), Anonymous: Disabled
+
+ ## Query Parameters
+
+ ### Pagination
+ - **page** (integer): Page number (default: 1, min: 1)
+ - **pageSize** (integer): Items per page (default: 20, min: 1, max: 100)
+
+ ### Sorting
+ - **sortBy** (string): Field to sort by (default: "created_at")
+ - Options: "created_at", "last_click", "total_clicks"
+ - **sortOrder** (string): Sort direction (default: "descending")
+ - Options: "ascending"/"asc"/"1", "descending"/"desc"/"-1"
+
+ ### Filtering
+ You can provide filters in two ways:
+
+ #### Method 1: JSON filter object (recommended for complex filters)
+ - **filter** (JSON string): Complex filter object
+ ```json
+ {
+ "status": "ACTIVE",
+ "createdAfter": "2024-01-01T00:00:00Z",
+ "createdBefore": "2024-12-31T23:59:59Z",
+ "passwordSet": true,
+ "maxClicksSet": false,
+ "search": "example"
+ }
+ ```
+
+ #### Method 2: Individual query parameters
+ All filter fields can also be passed as individual query parameters:
+ - **status** (string): Filter by status ("ACTIVE" or "INACTIVE")
+ - **createdAfter** (string): ISO 8601 datetime or Unix timestamp
+ - **createdBefore** (string): ISO 8601 datetime or Unix timestamp
+ - **passwordSet** (boolean): Filter by password protection (true/false)
+ - **maxClicksSet** (boolean): Filter by click limit presence (true/false)
+ - **search** (string): Search in alias or long_url (case-insensitive)
+
+ ## Example Requests
+
+ ### Basic pagination
+ ```
+ GET /api/v1/urls?page=1&pageSize=20
+ ```
+
+ ### With sorting
+ ```
+ GET /api/v1/urls?sortBy=total_clicks&sortOrder=descending
+ ```
+
+ ### With JSON filter
+ ```
+ GET /api/v1/urls?filter={"status":"ACTIVE","passwordSet":true}
+ ```
+
+ ### With search
+ ```
+ GET /api/v1/urls?filter={"search":"example"}
+ ```
+
+ ### Combined example
+ ```
+ GET /api/v1/urls?page=2&pageSize=50&sortBy=last_click&sortOrder=desc&filter={"status":"ACTIVE","createdAfter":"2024-01-01"}
+ ```
+
+ ## Response Format
+ ```json
+ {
+ "items": [
+ {
+ "id": "507f1f77bcf86cd799439011",
+ "alias": "mylink",
+ "long_url": "https://example.com/destination",
+ "status": "ACTIVE",
+ "created_at": "2024-01-01T12:00:00Z",
+ "expire_after": 1735689600,
+ "max_clicks": 100,
+ "private_stats": false,
+ "block_bots": false,
+ "password_set": true,
+ "total_clicks": 42,
+ "last_click": "2024-01-07T15:30:00Z"
+ }
+ ],
+ "page": 1,
+ "pageSize": 20,
+ "total": 150,
+ "hasNext": true,
+ "sortBy": "created_at",
+ "sortOrder": "descending"
+ }
+ ```
+
+ ## Response Fields
+ - **items**: Array of URL objects
+ - **page**: Current page number
+ - **pageSize**: Items per page
+ - **total**: Total number of URLs matching filters
+ - **hasNext**: Boolean indicating if more pages exist
+ - **sortBy**: Field used for sorting
+ - **sortOrder**: Sort direction applied
+
+ ## Error Responses
+ - **400**: Invalid pagination parameters, invalid filter JSON, invalid sort field
+ - **401**: Authentication required, invalid token
+ - **403**: Insufficient permissions (missing required scope)
+ - **429**: Rate limit exceeded
+ - **500**: Database/server error
+
+ Returns:
+ tuple[Response, int]: JSON response with paginated URL list and HTTP status code
+ """
+ owner_id = resolve_owner_id_from_request()
+ if owner_id is None:
+ return jsonify({"error": "authentication required"}), 401
+
+ builder = (
+ UrlListQueryBuilder(owner_id, request.args)
+ .parse_auth_scope()
+ .parse_pagination()
+ .parse_sort()
+ .parse_filters()
+ )
+ return builder.build()
diff --git a/blueprints/__init__.py b/blueprints/__init__.py
new file mode 100644
index 00000000..3f39942b
--- /dev/null
+++ b/blueprints/__init__.py
@@ -0,0 +1,35 @@
+# Blueprint imports for the URL shortener application
+
+# Core authentication (email/password)
+from .auth import auth
+
+# OAuth authentication (Google, etc.)
+from .oauth import oauth_bp, init_oauth_for_app
+
+# Dashboard routes (settings, links, keys, statistics)
+from .dashboard import dashboard_bp
+
+# Other blueprints
+from .api import api
+from .contact import contact
+from .docs import docs
+from .limiter import limiter
+from .seo import seo
+from .stats import stats
+from .url_shortener import url_shortener
+from .redirector import url_redirector
+
+__all__ = [
+ "auth",
+ "oauth_bp",
+ "init_oauth_for_app",
+ "dashboard_bp",
+ "api",
+ "contact",
+ "docs",
+ "limiter",
+ "seo",
+ "stats",
+ "url_shortener",
+ "url_redirector",
+]
diff --git a/blueprints/api.py b/blueprints/api.py
index f1098875..632fada1 100644
--- a/blueprints/api.py
+++ b/blueprints/api.py
@@ -16,4 +16,4 @@ def api_route():
self_promo_text="We have moved and revamped the docs to https://docs.spoo.me",
)
else:
- return redirect("https://docs.spoo.me/api"), 301
+ return redirect("https://docs.spoo.me/introduction"), 301
diff --git a/blueprints/auth.py b/blueprints/auth.py
new file mode 100644
index 00000000..be94f942
--- /dev/null
+++ b/blueprints/auth.py
@@ -0,0 +1,618 @@
+from datetime import datetime, timezone
+
+from flask import Blueprint, jsonify, request, g, redirect, render_template
+from pymongo.errors import DuplicateKeyError
+
+from .limiter import limiter, rate_limit_key_for_request
+from utils.logger import get_logger
+from utils.auth_utils import (
+ verify_password,
+ hash_password,
+ generate_access_jwt,
+ generate_refresh_jwt,
+ verify_refresh_jwt,
+ set_refresh_cookie,
+ set_access_cookie,
+ clear_refresh_cookie,
+ clear_access_cookie,
+ requires_auth,
+)
+from utils.password_utils import validate_password
+from utils.mongo_utils import (
+ get_user_by_email,
+ get_user_by_id,
+ users_collection,
+)
+from utils.url_utils import get_client_ip
+from utils.auth_utils import get_user_profile
+from utils.verification_utils import (
+ create_email_verification_otp,
+ create_password_reset_otp,
+ verify_otp,
+ is_rate_limited,
+ TOKEN_TYPE_EMAIL_VERIFY,
+ TOKEN_TYPE_PASSWORD_RESET,
+)
+from utils.email_service import email_service
+import jwt
+from bson import ObjectId
+
+auth = Blueprint("auth", __name__)
+log = get_logger(__name__)
+
+
+@auth.route("/auth/login", methods=["POST"])
+@limiter.limit("5/minute")
+@limiter.limit("50/day")
+def login():
+ body = request.get_json(silent=True) or {}
+ email = (body.get("email") or "").strip().lower()
+ password = body.get("password") or ""
+ if not email or not password:
+ return jsonify({"error": "email and password are required"}), 400
+
+ user = get_user_by_email(email)
+ if not user or not user.get("password_hash"):
+ # Do not reveal which part failed
+ log.warning(
+ "login_failed", reason="invalid_credentials", email_exists=bool(user)
+ )
+ return jsonify({"error": "invalid credentials"}), 401
+
+ if not verify_password(password, user["password_hash"]):
+ log.warning("login_failed", reason="invalid_password", user_id=str(user["_id"]))
+ return jsonify({"error": "invalid credentials"}), 401
+
+ email_verified = user.get("email_verified", False)
+ access_token = generate_access_jwt(str(user["_id"]), email_verified)
+ refresh_token = generate_refresh_jwt(str(user["_id"]), email_verified)
+
+ log.info("login_success", user_id=str(user["_id"]), auth_method="password")
+
+ resp = jsonify({"access_token": access_token, "user": get_user_profile(user)})
+ set_refresh_cookie(resp, refresh_token)
+ set_access_cookie(resp, access_token)
+ return resp, 200
+
+
+@auth.route("/auth/refresh", methods=["POST"])
+@limiter.limit("20/minute")
+def refresh():
+ refresh_token = request.cookies.get("refresh_token")
+ if not refresh_token:
+ return jsonify({"error": "missing refresh token"}), 401
+
+ try:
+ # Verify refresh token (stateless)
+ refresh_claims = verify_refresh_jwt(refresh_token)
+ user_id = refresh_claims.get("sub")
+
+ # Fetch user and ensure they still exist and are active
+ user = get_user_by_id(user_id)
+ if not user or user.get("status") != "ACTIVE":
+ log.warning(
+ "token_refresh_failed",
+ reason="user_not_found_or_inactive",
+ user_id=user_id,
+ )
+ return jsonify({"error": "invalid or expired refresh token"}), 401
+
+ email_verified = user.get("email_verified", False)
+
+ # Generate new tokens (token rotation for security)
+ new_access_token = generate_access_jwt(user_id, email_verified)
+ new_refresh_token = generate_refresh_jwt(user_id, email_verified)
+
+ log.info("token_refreshed", user_id=user_id)
+
+ resp = jsonify({"access_token": new_access_token})
+ set_refresh_cookie(resp, new_refresh_token)
+ set_access_cookie(resp, new_access_token)
+ return resp, 200
+
+ except (jwt.ExpiredSignatureError, jwt.InvalidTokenError) as e:
+ log.warning("token_refresh_failed", reason="expired_or_invalid", error=str(e))
+ resp = jsonify({"error": "invalid or expired refresh token"})
+ clear_refresh_cookie(resp)
+ clear_access_cookie(resp)
+ return resp, 401
+ except Exception as e:
+ log.error("token_refresh_error", error=str(e), error_type=type(e).__name__)
+ return jsonify({"error": "refresh token verification failed"}), 401
+
+
+@auth.route("/auth/logout", methods=["POST"])
+@limiter.limit("60/hour")
+def logout():
+ user_id = getattr(g, "user_id", None)
+ if user_id:
+ log.info("logout", user_id=str(user_id))
+
+ resp = jsonify({"success": True})
+ clear_refresh_cookie(resp)
+ clear_access_cookie(resp)
+ return resp, 200
+
+
+@auth.route("/auth/me", methods=["GET"])
+@requires_auth
+@limiter.limit("60/minute", key_func=rate_limit_key_for_request)
+def me():
+ user_id = g.user_id
+ user = get_user_by_id(user_id)
+ if not user:
+ return jsonify({"error": "user not found"}), 404
+ return jsonify({"user": get_user_profile(user)})
+
+
+@auth.route("/auth/register", methods=["POST"])
+@limiter.limit("5/minute")
+@limiter.limit("50/day")
+def register():
+ body = request.get_json(silent=True) or {}
+ email = (body.get("email") or "").strip().lower()
+ password = body.get("password") or ""
+ user_name = (body.get("user_name") or "").strip() or None
+ if not email or not password:
+ return jsonify({"error": "email and password are required"}), 400
+
+ # Validate password with comprehensive checks
+ is_valid, missing_requirements = validate_password(password)
+ if not is_valid:
+ return jsonify(
+ {
+ "error": "Password does not meet requirements",
+ "missing_requirements": missing_requirements,
+ }
+ ), 400
+
+ # Check existing user
+ existing = get_user_by_email(email)
+ if existing:
+ log.warning("registration_failed", reason="email_exists")
+ return jsonify({"error": "email already registered"}), 409
+
+ password_hash = hash_password(password)
+ user_doc = {
+ "email": email,
+ "email_verified": False, # Email not verified initially
+ "password_hash": password_hash,
+ "password_set": True,
+ "user_name": user_name,
+ "pfp": None,
+ "auth_providers": [],
+ "plan": "free",
+ "signup_ip": get_client_ip(),
+ "created_at": datetime.now(timezone.utc),
+ "updated_at": datetime.now(timezone.utc),
+ "status": "ACTIVE",
+ }
+ try:
+ insert_result = users_collection.insert_one(user_doc)
+ user_id = insert_result.inserted_id
+ except DuplicateKeyError:
+ # Race condition: email was registered between our check and insert
+ log.warning("registration_failed", reason="race_condition_duplicate")
+ return jsonify({"error": "email already registered"}), 409
+ except Exception as e:
+ log.error("registration_failed", reason="database_error", error=str(e))
+ return jsonify({"error": "failed to create user"}), 500
+
+ # Issue tokens for authentication (but user still needs to verify email)
+ access_token = generate_access_jwt(str(user_id), email_verified=False)
+ refresh_token = generate_refresh_jwt(str(user_id), email_verified=False)
+
+ log.info(
+ "user_registered",
+ user_id=str(user_id),
+ auth_method="password",
+ has_username=bool(user_name),
+ )
+
+ # Send verification email automatically
+ verification_sent = False
+ try:
+ success, otp_code, error = create_email_verification_otp(str(user_id), email)
+ if success and otp_code:
+ email_service.send_verification_email(email, user_name, otp_code)
+ verification_sent = True
+ log.info("registration_verification_email_sent", user_id=str(user_id))
+ except Exception as e:
+ # Don't fail registration if email sending fails
+ log.error(
+ "registration_verification_email_failed",
+ user_id=str(user_id),
+ error=str(e),
+ )
+
+ resp = jsonify(
+ {
+ "access_token": access_token,
+ "user": get_user_profile({"_id": user_id, **user_doc}),
+ "requires_verification": True,
+ "verification_sent": verification_sent,
+ }
+ )
+ set_refresh_cookie(resp, refresh_token)
+ set_access_cookie(resp, access_token)
+ return resp, 201
+
+
+@auth.route("/auth/set-password", methods=["POST"])
+@requires_auth
+@limiter.limit("5/minute")
+def set_password():
+ """Set password for OAuth-only users"""
+ user = get_user_by_id(g.user_id)
+ if not user:
+ return jsonify({"error": "user not found"}), 404
+
+ if user.get("password_set", False):
+ return jsonify({"error": "password already set"}), 400
+
+ body = request.get_json(silent=True) or {}
+ password = body.get("password") or ""
+
+ if not password:
+ return jsonify({"error": "password is required"}), 400
+
+ # Validate password with comprehensive checks
+ is_valid, missing_requirements = validate_password(password)
+ if not is_valid:
+ return jsonify(
+ {
+ "error": "Password does not meet requirements",
+ "missing_requirements": missing_requirements,
+ }
+ ), 400
+
+ try:
+ password_hash = hash_password(password)
+
+ result = users_collection.update_one(
+ {"_id": ObjectId(g.user_id)},
+ {
+ "$set": {
+ "password_hash": password_hash,
+ "password_set": True,
+ "updated_at": datetime.now(timezone.utc),
+ }
+ },
+ )
+
+ if result.modified_count > 0:
+ log.info("password_set", user_id=g.user_id)
+ return jsonify({"success": True, "message": "password set successfully"})
+ else:
+ log.error("password_set_failed", user_id=g.user_id, reason="no_update")
+ return jsonify({"error": "failed to set password"}), 500
+
+ except Exception as e:
+ log.error(
+ "password_set_failed",
+ user_id=g.user_id,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return jsonify({"error": "failed to set password"}), 500
+
+
+@auth.route("/login", methods=["GET"])
+def login_redirect():
+ """Redirect /login to home page to prevent shortened URL conflicts"""
+ return redirect("/", code=302)
+
+
+@auth.route("/register", methods=["GET"])
+@auth.route("/signup", methods=["GET"])
+def register_redirect():
+ """Redirect /register and /signup to home page to prevent shortened URL conflicts"""
+ return redirect("/", code=302)
+
+
+@auth.route("/auth/verify", methods=["GET"])
+@requires_auth
+@limiter.limit("60/minute", key_func=rate_limit_key_for_request)
+def verify_page():
+ """Email verification page"""
+ user = get_user_by_id(g.user_id)
+ if not user:
+ return jsonify({"error": "user not found"}), 404
+
+ # Redirect to dashboard if already verified
+ if user.get("email_verified", False):
+ return redirect("/dashboard")
+
+ return render_template("verify.html", email=user.get("email"))
+
+
+@auth.route("/auth/send-verification", methods=["POST"])
+@requires_auth
+@limiter.limit("3/hour", key_func=rate_limit_key_for_request)
+def send_verification_email():
+ """Send email verification OTP to authenticated user"""
+ user = get_user_by_id(g.user_id)
+ if not user:
+ return jsonify({"error": "user not found"}), 404
+
+ if user.get("email_verified", False):
+ return jsonify({"error": "email already verified"}), 400
+
+ # Check rate limiting
+ if is_rate_limited(g.user_id, TOKEN_TYPE_EMAIL_VERIFY):
+ log.warning("verification_rate_limited", user_id=g.user_id)
+ return (
+ jsonify(
+ {
+ "error": "too many requests",
+ "message": "Please wait before requesting another verification email",
+ }
+ ),
+ 429,
+ )
+
+ # Create OTP
+ success, otp_code, error = create_email_verification_otp(g.user_id, user["email"])
+
+ if not success:
+ return jsonify({"error": error or "failed to create verification code"}), 500
+
+ # Send email
+ email_sent = email_service.send_verification_email(
+ user["email"], user.get("user_name"), otp_code
+ )
+
+ if not email_sent:
+ log.error("verification_email_send_failed", user_id=g.user_id)
+ return jsonify({"error": "failed to send verification email"}), 500
+
+ log.info("verification_email_sent", user_id=g.user_id, email=user["email"])
+
+ return jsonify(
+ {
+ "success": True,
+ "message": "verification code sent to your email",
+ "expires_in": 600,
+ }
+ )
+
+
+@auth.route("/auth/verify-email", methods=["POST"])
+@requires_auth
+@limiter.limit("10/hour", key_func=rate_limit_key_for_request)
+def verify_email():
+ """Verify email using OTP code"""
+ user = get_user_by_id(g.user_id)
+ if not user:
+ return jsonify({"error": "user not found"}), 404
+
+ if user.get("email_verified", False):
+ return jsonify({"error": "email already verified"}), 400
+
+ body = request.get_json(silent=True) or {}
+ otp_code = (body.get("code") or "").strip()
+
+ if not otp_code:
+ return jsonify({"error": "verification code is required"}), 400
+
+ # Verify OTP
+ success, error = verify_otp(g.user_id, otp_code, TOKEN_TYPE_EMAIL_VERIFY)
+
+ if not success:
+ log.warning("email_verification_failed", user_id=g.user_id, error=error)
+ return jsonify({"error": error or "invalid verification code"}), 400
+
+ # Update user's email_verified status
+ try:
+ result = users_collection.update_one(
+ {"_id": ObjectId(g.user_id)},
+ {
+ "$set": {
+ "email_verified": True,
+ "updated_at": datetime.now(timezone.utc),
+ }
+ },
+ )
+
+ if result.modified_count > 0:
+ log.info("email_verified_success", user_id=g.user_id)
+
+ # Issue new JWT tokens with email_verified=True
+ new_access_token = generate_access_jwt(g.user_id, email_verified=True)
+ new_refresh_token = generate_refresh_jwt(g.user_id, email_verified=True)
+
+ # Best-effort welcome email; don't fail verification if this breaks
+ try:
+ email_service.send_welcome_email(user["email"], user.get("user_name"))
+ except Exception as mail_exc:
+ log.error(
+ "welcome_email_send_failed",
+ user_id=g.user_id,
+ error=str(mail_exc),
+ error_type=type(mail_exc).__name__,
+ )
+
+ resp = jsonify(
+ {
+ "success": True,
+ "message": "email verified successfully",
+ "email_verified": True,
+ }
+ )
+ set_refresh_cookie(resp, new_refresh_token)
+ set_access_cookie(resp, new_access_token)
+ return resp
+ else:
+ log.error("email_verification_update_failed", user_id=g.user_id)
+ return jsonify({"error": "failed to update verification status"}), 500
+
+ except Exception as e:
+ log.error(
+ "email_verification_error",
+ user_id=g.user_id,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return jsonify({"error": "failed to verify email"}), 500
+
+
+@auth.route("/auth/request-password-reset", methods=["POST"])
+@limiter.limit("3/hour")
+def request_password_reset():
+ """Request password reset OTP"""
+ body = request.get_json(silent=True) or {}
+ email = (body.get("email") or "").strip().lower()
+
+ if not email:
+ return jsonify({"error": "email is required"}), 400
+
+ # Find user by email
+ user = get_user_by_email(email)
+
+ # Always return success to prevent email enumeration
+ if not user:
+ log.warning("password_reset_requested_nonexistent", email=email)
+ return jsonify(
+ {
+ "success": True,
+ "message": "if the email exists, a reset code has been sent",
+ }
+ )
+
+ # Check if user has a password set
+ if not user.get("password_set", False):
+ log.warning("password_reset_no_password", user_id=str(user["_id"]))
+ # Still return success for security
+ return jsonify(
+ {
+ "success": True,
+ "message": "if the email exists, a reset code has been sent",
+ }
+ )
+
+ user_id = str(user["_id"])
+
+ # Check rate limiting
+ if is_rate_limited(user_id, TOKEN_TYPE_PASSWORD_RESET):
+ log.warning("password_reset_rate_limited", user_id=user_id)
+ # Don't reveal rate limiting for security
+ return jsonify(
+ {
+ "success": True,
+ "message": "if the email exists, a reset code has been sent",
+ }
+ )
+
+ # Create OTP
+ success, otp_code, error = create_password_reset_otp(user_id, email)
+
+ if not success:
+ log.error("password_reset_otp_creation_failed", user_id=user_id, error=error)
+ # Still return success for security
+ return jsonify(
+ {
+ "success": True,
+ "message": "if the email exists, a reset code has been sent",
+ }
+ )
+
+ # Send email
+ email_sent = email_service.send_password_reset_email(
+ email, user.get("user_name"), otp_code
+ )
+
+ if not email_sent:
+ log.error("password_reset_email_send_failed", user_id=user_id)
+ # Still return success for security
+ return jsonify(
+ {
+ "success": True,
+ "message": "if the email exists, a reset code has been sent",
+ }
+ )
+
+ log.info("password_reset_email_sent", user_id=user_id)
+
+ return jsonify(
+ {
+ "success": True,
+ "message": "if the email exists, a reset code has been sent",
+ "expires_in": 600,
+ }
+ )
+
+
+@auth.route("/auth/reset-password", methods=["POST"])
+@limiter.limit("5/hour")
+def reset_password():
+ """Reset password using OTP code"""
+ body = request.get_json(silent=True) or {}
+ email = (body.get("email") or "").strip().lower()
+ otp_code = (body.get("code") or "").strip()
+ new_password = body.get("password") or ""
+
+ if not email or not otp_code or not new_password:
+ return (
+ jsonify({"error": "email, code, and password are required"}),
+ 400,
+ )
+
+ # Find user
+ user = get_user_by_email(email)
+ if not user:
+ return jsonify({"error": "invalid email or code"}), 400
+
+ user_id = str(user["_id"])
+
+ # Validate new password
+ is_valid, missing_requirements = validate_password(new_password)
+ if not is_valid:
+ return jsonify(
+ {
+ "error": "password does not meet requirements",
+ "missing_requirements": missing_requirements,
+ }
+ ), 400
+
+ # Verify OTP
+ success, error = verify_otp(user_id, otp_code, TOKEN_TYPE_PASSWORD_RESET)
+
+ if not success:
+ log.warning("password_reset_verification_failed", user_id=user_id, error=error)
+ return jsonify({"error": error or "invalid or expired code"}), 400
+
+ # Update password
+ try:
+ password_hash = hash_password(new_password)
+
+ result = users_collection.update_one(
+ {"_id": ObjectId(user_id)},
+ {
+ "$set": {
+ "password_hash": password_hash,
+ "password_set": True,
+ "updated_at": datetime.now(timezone.utc),
+ }
+ },
+ )
+
+ if result.modified_count > 0:
+ log.info("password_reset_success", user_id=user_id)
+ return jsonify(
+ {
+ "success": True,
+ "message": "password reset successfully",
+ }
+ )
+ else:
+ log.error("password_reset_update_failed", user_id=user_id)
+ return jsonify({"error": "failed to reset password"}), 500
+
+ except Exception as e:
+ log.error(
+ "password_reset_error",
+ user_id=user_id,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return jsonify({"error": "failed to reset password"}), 500
diff --git a/blueprints/contact.py b/blueprints/contact.py
index ccf3211b..6a573d0c 100644
--- a/blueprints/contact.py
+++ b/blueprints/contact.py
@@ -6,11 +6,13 @@
CONTACT_WEBHOOK,
URL_REPORT_WEBHOOK,
)
-from utils.mongo_utils import check_if_slug_exists
+from utils.mongo_utils import check_if_slug_exists, check_if_v2_alias_exists
from utils.url_utils import get_client_ip
+from utils.logger import get_logger
from .limiter import limiter
contact = Blueprint("contact", __name__)
+log = get_logger(__name__)
@contact.route("/contact", methods=["GET", "POST"])
@@ -59,8 +61,18 @@ def contact_route():
try:
send_contact_message(CONTACT_WEBHOOK, email, message)
+ log.info(
+ "contact_message_sent",
+ email_domain=email.split("@")[1] if "@" in email else "unknown",
+ message_length=len(message),
+ )
except Exception as e:
- print(f"Error sending webhook: {e}")
+ log.error(
+ "webhook_send_failed",
+ webhook_type="contact",
+ error=str(e),
+ error_type=type(e).__name__,
+ )
return render_template(
"contact.html",
error="Error sending message, please try again later",
@@ -81,9 +93,10 @@ def contact_route():
@limiter.limit("3/minute")
def report():
if request.method == "POST":
- short_code = request.values.get("short_code")
- reason = request.values.get("reason")
- hcaptcha_token = request.values.get("h-captcha-response")
+ # Only read from form data (POST), not query parameters
+ short_code = request.form.get("short_code")
+ reason = request.form.get("reason")
+ hcaptcha_token = request.form.get("h-captcha-response")
if not hcaptcha_token:
return (
@@ -120,7 +133,13 @@ def report():
)
short_code = short_code.split("/")[-1]
- if not check_if_slug_exists(short_code):
+
+ # Check both v1 (urls) and v2 (urlsV2) collections
+ url_exists = check_if_slug_exists(short_code) or check_if_v2_alias_exists(
+ short_code
+ )
+
+ if not url_exists:
return (
render_template(
"report.html",
@@ -138,8 +157,19 @@ def report():
get_client_ip(),
request.host_url,
)
+ log.info(
+ "url_report_sent",
+ short_code=short_code,
+ reason=reason[:50], # Truncate reason for logging
+ )
except Exception as e:
- print(f"Error sending webhook: {e}")
+ log.error(
+ "webhook_send_failed",
+ webhook_type="report",
+ short_code=short_code,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
return render_template(
"report.html",
error="Error sending report, please try again later",
diff --git a/blueprints/dashboard.py b/blueprints/dashboard.py
new file mode 100644
index 00000000..70f7fd45
--- /dev/null
+++ b/blueprints/dashboard.py
@@ -0,0 +1,214 @@
+from flask import Blueprint, jsonify, request, g, render_template, redirect
+from datetime import datetime, timezone
+from bson import ObjectId
+
+from utils.auth_utils import (
+ requires_auth,
+)
+from utils.mongo_utils import (
+ get_user_by_id,
+ users_collection,
+)
+from utils.auth_utils import get_user_profile
+from blueprints.limiter import limiter, rate_limit_key_for_request
+from utils.logger import get_logger
+
+log = get_logger(__name__)
+
+
+dashboard_bp = Blueprint("dashboard", __name__)
+
+
+@dashboard_bp.route("/", methods=["GET"])
+@limiter.limit("60 per minute", key_func=rate_limit_key_for_request)
+@requires_auth
+def dashboard():
+ # Redirect to links page as the default dashboard view
+ return redirect("/dashboard/links")
+
+
+@dashboard_bp.route("/links", methods=["GET"])
+@requires_auth
+@limiter.limit("60 per minute", key_func=rate_limit_key_for_request)
+def dashboard_links():
+ user = get_user_by_id(g.user_id)
+ if not user:
+ log.error("dashboard_user_not_found", user_id=str(g.user_id), page="links")
+ return jsonify({"error": "user not found"}), 404
+ return render_template(
+ "dashboard/links.html",
+ host_url=request.host_url,
+ user=get_user_profile(user),
+ )
+
+
+@dashboard_bp.route("/keys", methods=["GET"])
+@limiter.limit("60 per minute", key_func=rate_limit_key_for_request)
+@requires_auth
+def dashboard_keys():
+ user = get_user_by_id(g.user_id)
+ if not user:
+ log.error("dashboard_user_not_found", user_id=str(g.user_id), page="keys")
+ return jsonify({"error": "user not found"}), 404
+ return render_template(
+ "dashboard/keys.html",
+ host_url=request.host_url,
+ user=get_user_profile(user),
+ )
+
+
+@dashboard_bp.route("/statistics", methods=["GET"])
+@requires_auth
+@limiter.limit(
+ "60 per minute", key_func=rate_limit_key_for_request
+) # same as authenticated limit in stats API
+def dashboard_statistics():
+ user = get_user_by_id(g.user_id)
+ if not user:
+ log.error("dashboard_user_not_found", user_id=str(g.user_id), page="statistics")
+ return jsonify({"error": "user not found"}), 404
+ return render_template(
+ "dashboard/statistics.html",
+ host_url=request.host_url,
+ user=get_user_profile(user),
+ )
+
+
+@dashboard_bp.route("/settings", methods=["GET"])
+@requires_auth
+@limiter.limit("60 per minute", key_func=rate_limit_key_for_request)
+def dashboard_settings():
+ user = get_user_by_id(g.user_id)
+ if not user:
+ log.error("dashboard_user_not_found", user_id=str(g.user_id), page="settings")
+ return jsonify({"error": "user not found"}), 404
+ return render_template(
+ "dashboard/settings.html",
+ host_url=request.host_url,
+ user=get_user_profile(user),
+ )
+
+
+@dashboard_bp.route("/billing", methods=["GET"])
+@requires_auth
+@limiter.limit("60 per minute", key_func=rate_limit_key_for_request)
+def dashboard_billing():
+ user = get_user_by_id(g.user_id)
+ if not user:
+ log.error("dashboard_user_not_found", user_id=str(g.user_id), page="billing")
+ return jsonify({"error": "user not found"}), 404
+ return render_template(
+ "dashboard/billing.html",
+ host_url=request.host_url,
+ user=get_user_profile(user),
+ )
+
+
+@dashboard_bp.route("/profile-pictures", methods=["GET"])
+@limiter.limit("30 per minute", key_func=rate_limit_key_for_request)
+@requires_auth
+def get_profile_pictures():
+ """Get available profile pictures from connected OAuth providers"""
+ user = get_user_by_id(g.user_id)
+ if not user:
+ log.error(
+ "dashboard_user_not_found", user_id=str(g.user_id), page="profile_pictures"
+ )
+ return jsonify({"error": "user not found"}), 404
+
+ pictures = []
+ current_pfp_url = user.get("pfp", {}).get("url")
+
+ # Get pictures from OAuth providers
+ for provider in user.get("auth_providers", []):
+ picture_url = provider.get("profile", {}).get("picture")
+ if picture_url:
+ pictures.append(
+ {
+ "id": f"{provider.get('provider')}_{provider.get('provider_user_id')}",
+ "url": picture_url,
+ "source": provider.get("provider"),
+ "is_current": current_pfp_url == picture_url,
+ }
+ )
+
+ return jsonify({"pictures": pictures})
+
+
+@dashboard_bp.route("/profile-pictures", methods=["POST"])
+@limiter.limit("5 per minute", key_func=rate_limit_key_for_request)
+@requires_auth
+def set_profile_picture():
+ """Set user's profile picture from available options"""
+ data = request.get_json()
+ if not data or "picture_id" not in data:
+ log.info(
+ "profile_picture_update_failed",
+ user_id=str(g.user_id),
+ reason="missing_picture_id",
+ )
+ return jsonify({"error": "picture_id is required"}), 400
+
+ picture_id = data["picture_id"]
+ user = get_user_by_id(g.user_id)
+ if not user:
+ log.error(
+ "dashboard_user_not_found",
+ user_id=str(g.user_id),
+ page="profile_pictures_update",
+ )
+ return jsonify({"error": "user not found"}), 404
+
+ # Find the picture from OAuth providers
+ for provider in user.get("auth_providers", []):
+ provider_id = f"{provider.get('provider')}_{provider.get('provider_user_id')}"
+ if provider_id == picture_id:
+ picture_url = provider.get("profile", {}).get("picture")
+ if picture_url:
+ # Update user's profile picture
+ result = users_collection.update_one(
+ {"_id": ObjectId(g.user_id)},
+ {
+ "$set": {
+ "pfp": {
+ "url": picture_url,
+ "source": provider.get("provider"),
+ "last_updated": datetime.now(timezone.utc),
+ }
+ }
+ },
+ )
+
+ # Check if the update was successful (idempotent)
+ if not result.acknowledged:
+ log.error(
+ "profile_picture_update_failed",
+ user_id=str(g.user_id),
+ reason="update_not_acknowledged",
+ picture_id=picture_id,
+ )
+ return jsonify({"error": "Failed to update profile picture"}), 500
+ if result.matched_count == 0:
+ log.error(
+ "profile_picture_update_failed",
+ user_id=str(g.user_id),
+ reason="user_not_found",
+ picture_id=picture_id,
+ )
+ return jsonify({"error": "user not found"}), 404
+
+ log.info(
+ "profile_picture_updated",
+ user_id=str(g.user_id),
+ source=provider.get("provider"),
+ picture_id=picture_id,
+ )
+ return jsonify({"message": "Profile picture updated successfully"})
+
+ log.warning(
+ "profile_picture_update_failed",
+ user_id=str(g.user_id),
+ reason="picture_not_found",
+ picture_id=picture_id,
+ )
+ return jsonify({"error": "Picture not found"}), 404
diff --git a/blueprints/limiter.py b/blueprints/limiter.py
index 186177af..dd16484c 100644
--- a/blueprints/limiter.py
+++ b/blueprints/limiter.py
@@ -1,7 +1,12 @@
from flask_limiter import Limiter
from utils.mongo_utils import MONGO_URI, ip_bypasses
from utils.url_utils import get_client_ip
+from utils.logger import get_logger
from flask import request
+from utils.auth_utils import resolve_owner_id_from_request
+import hashlib
+
+log = get_logger(__name__)
limiter = Limiter(
key_func=get_client_ip, # Use custom function that handles Cloudflare/proxy headers
@@ -14,11 +19,39 @@
@limiter.request_filter
def ip_whitelist():
- if request.method == "GET":
- return True
-
+ """Skip rate limiting for whitelisted IPs"""
bypasses = ip_bypasses.find()
bypasses = [doc["_id"] for doc in bypasses]
client_ip = get_client_ip()
return client_ip in bypasses
+
+
+def dynamic_limit_for_request(
+ *,
+ authenticated: str = "60 per minute; 5000 per day",
+ anonymous: str = "20 per minute; 1000 per day",
+) -> str:
+ """Higher limits for authenticated/API-key users, lower for anonymous.
+
+ You can override the defaults per-endpoint by calling this with custom values:
+ dynamic_limit_for_request(authenticated="120 per minute", anonymous="30 per minute")
+ """
+ owner_id = resolve_owner_id_from_request()
+ if owner_id is not None:
+ return authenticated
+ return anonymous
+
+
+def rate_limit_key_for_request() -> str:
+ """Bucket by user id when authenticated, else by API key prefix if provided, else IP."""
+ owner_id = resolve_owner_id_from_request()
+ if owner_id is not None:
+ return f"user:{str(owner_id)}"
+ auth_header = request.headers.get("Authorization", "")
+ if auth_header.lower().startswith("bearer "):
+ token = auth_header.split(" ", 1)[1].strip()
+ if token.startswith("spoo_"):
+ token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
+ return f"apikey:{token_hash}"
+ return get_client_ip()
diff --git a/blueprints/oauth.py b/blueprints/oauth.py
new file mode 100644
index 00000000..a3fa14ca
--- /dev/null
+++ b/blueprints/oauth.py
@@ -0,0 +1,956 @@
+from datetime import datetime, timezone
+from flask import Blueprint, jsonify, request, g, redirect
+
+from .limiter import limiter
+from utils.logger import get_logger
+from utils.email_service import email_service
+from utils.auth_utils import (
+ generate_access_jwt,
+ generate_refresh_jwt,
+ set_refresh_cookie,
+ set_access_cookie,
+ requires_auth,
+)
+from utils.mongo_utils import (
+ get_user_by_email,
+ get_user_by_id,
+ users_collection,
+)
+from utils.oauth_utils import (
+ init_oauth,
+ generate_oauth_state,
+ verify_oauth_state,
+ extract_user_info_from_google,
+ extract_user_info_from_github,
+ extract_user_info_from_discord,
+ find_user_by_provider,
+ create_oauth_user,
+ link_provider_to_user,
+ can_auto_link_accounts,
+ update_user_last_login,
+ get_oauth_redirect_url,
+ OAuthProviders,
+)
+
+
+oauth_bp = Blueprint("oauth", __name__)
+log = get_logger(__name__)
+
+# Initialize OAuth - this needs to be done at the app level
+oauth = None
+providers = {}
+
+
+def init_oauth_for_app(app):
+ """Initialize OAuth with the Flask app"""
+ global oauth, providers
+ oauth, providers = init_oauth(app)
+ return oauth, providers
+
+
+@oauth_bp.route("/google", methods=["GET"])
+@limiter.limit("10/minute")
+def oauth_google_login():
+ """Initiate Google OAuth login"""
+ google = providers.get("google")
+ if not google:
+ log.error("oauth_provider_not_configured", provider="google")
+ return jsonify({"error": "Google OAuth not configured"}), 500
+
+ # Generate state parameter for CSRF protection
+ state = generate_oauth_state(OAuthProviders.GOOGLE, "login")
+
+ # Get redirect URI
+ redirect_uri = get_oauth_redirect_url(OAuthProviders.GOOGLE)
+
+ # Redirect to Google OAuth
+ return google.authorize_redirect(redirect_uri, state=state)
+
+
+@oauth_bp.route("/google/callback", methods=["GET"])
+@limiter.limit("20/minute")
+def oauth_google_callback():
+ """Handle Google OAuth callback"""
+ google = providers.get("google")
+ if not google:
+ log.error("oauth_provider_not_configured", provider="google")
+ return jsonify({"error": "Google OAuth not configured"}), 500
+
+ # Verify state parameter
+ state = request.args.get("state")
+ if not state:
+ log.warning("oauth_state_missing", provider="google")
+ return jsonify({"error": "missing state parameter"}), 400
+
+ is_valid, state_data = verify_oauth_state(state, OAuthProviders.GOOGLE)
+ if not is_valid:
+ log.warning("oauth_state_invalid", provider="google")
+ return jsonify({"error": "invalid state parameter"}), 400
+
+ # Check for error from OAuth provider
+ error = request.args.get("error")
+ if error:
+ error_description = request.args.get(
+ "error_description", "OAuth authorization failed"
+ )
+ log.warning(
+ "oauth_provider_error",
+ provider="google",
+ error=error,
+ description=error_description,
+ )
+ return jsonify({"error": f"OAuth error: {error_description}"}), 400
+
+ try:
+ # Exchange authorization code for token
+ token = google.authorize_access_token()
+
+ # Get user info from Google
+ userinfo = token.get("userinfo")
+ if not userinfo:
+ # Fallback: fetch userinfo manually
+ resp = google.get("userinfo", token=token)
+ userinfo = resp.json()
+
+ # Extract standardized user info
+ provider_info = extract_user_info_from_google(userinfo)
+
+ if not provider_info["email"]:
+ return jsonify({"error": "email not provided by OAuth provider"}), 400
+
+ # Check if this is a linking action
+ action = state_data.get("action", "login")
+
+ if action == "link":
+ # This is an account linking request
+ link_user_id = state_data.get("user_id")
+ if not link_user_id:
+ return jsonify({"error": "invalid linking request"}), 400
+
+ # Verify user exists
+ current_user = get_user_by_id(link_user_id)
+ if not current_user:
+ return jsonify({"error": "user not found"}), 404
+
+ # Check if provider is already linked to this user
+ auth_providers = current_user.get("auth_providers", [])
+ for provider_entry in auth_providers:
+ if provider_entry.get("provider") == OAuthProviders.GOOGLE:
+ return jsonify({"error": "Google account already linked"}), 409
+
+ # Check if this Google account is already linked to another user
+ existing_oauth_user = find_user_by_provider(
+ OAuthProviders.GOOGLE, provider_info["provider_user_id"]
+ )
+ if existing_oauth_user and str(existing_oauth_user["_id"]) != link_user_id:
+ return jsonify(
+ {"error": "This Google account is already linked to another user"}
+ ), 409
+
+ # Verify that the OAuth email matches the current user's email
+ if current_user.get("email", "").lower() != provider_info["email"].lower():
+ log.warning(
+ "oauth_email_mismatch",
+ user_id=link_user_id,
+ provider="google",
+ reason="linking_attempt",
+ )
+ return jsonify(
+ {
+ "error": "email mismatch",
+ "message": f"The email associated with this Google account ({provider_info['email']}) does not match your account email ({current_user.get('email', '')}). Please use a Google account with the same email address.",
+ }
+ ), 400
+
+ # Link the provider to current user
+ if link_provider_to_user(
+ link_user_id, provider_info, OAuthProviders.GOOGLE
+ ):
+ log.info(
+ "oauth_account_linked", user_id=link_user_id, provider="google"
+ )
+
+ # Generate tokens for the linked user
+ auth_method = OAuthProviders.GOOGLE
+ access_token = generate_access_jwt(link_user_id, True, auth_method)
+ refresh_token = generate_refresh_jwt(link_user_id, True, auth_method)
+
+ # Set tokens in cookies and redirect
+ resp = redirect("/dashboard")
+ set_refresh_cookie(resp, refresh_token)
+ set_access_cookie(resp, access_token)
+ return resp
+ else:
+ log.error(
+ "oauth_linking_failed",
+ user_id=link_user_id,
+ provider="google",
+ reason="database_error",
+ )
+ return jsonify({"error": "failed to link Google account"}), 500
+
+ # Check if user already exists with this OAuth provider
+ existing_oauth_user = find_user_by_provider(
+ OAuthProviders.GOOGLE, provider_info["provider_user_id"]
+ )
+
+ if existing_oauth_user:
+ # User exists with this OAuth provider - log them in
+ user_id = str(existing_oauth_user["_id"])
+ update_user_last_login(user_id)
+
+ log.info(
+ "oauth_login_success",
+ user_id=user_id,
+ provider="google",
+ action="login",
+ )
+
+ # Generate tokens
+ auth_method = OAuthProviders.GOOGLE
+ access_token = generate_access_jwt(user_id, True, auth_method)
+ refresh_token = generate_refresh_jwt(user_id, True, auth_method)
+
+ # Set tokens in cookies and redirect
+ resp = redirect("/dashboard")
+ set_refresh_cookie(resp, refresh_token)
+ set_access_cookie(resp, access_token)
+ return resp
+
+ # Check if user exists with the same email
+ existing_email_user = get_user_by_email(provider_info["email"])
+
+ if existing_email_user:
+ # User exists with same email - check if we can auto-link
+ if can_auto_link_accounts(
+ existing_email_user, provider_info, OAuthProviders.GOOGLE
+ ):
+ # Auto-link the accounts
+ user_id = str(existing_email_user["_id"])
+
+ if link_provider_to_user(user_id, provider_info, OAuthProviders.GOOGLE):
+ update_user_last_login(user_id)
+
+ log.info("oauth_auto_linked", user_id=user_id, provider="google")
+
+ # Generate tokens
+ auth_method = OAuthProviders.GOOGLE
+ access_token = generate_access_jwt(user_id, True, auth_method)
+ refresh_token = generate_refresh_jwt(user_id, True, auth_method)
+
+ # Set tokens in cookies and redirect
+ resp = redirect("/dashboard")
+ set_refresh_cookie(resp, refresh_token)
+ set_access_cookie(resp, access_token)
+ return resp
+ else:
+ log.error(
+ "oauth_auto_link_failed", user_id=user_id, provider="google"
+ )
+ return jsonify({"error": "failed to link accounts"}), 500
+ else:
+ # Cannot auto-link - require manual account linking or different email
+ return jsonify(
+ {
+ "error": "email already exists",
+ "message": "An account with this email already exists. Please log in with your existing method first to link accounts.",
+ }
+ ), 409
+
+ # Create new user with OAuth
+ user_id = create_oauth_user(provider_info, OAuthProviders.GOOGLE)
+
+ if not user_id:
+ log.error("oauth_user_creation_failed", provider="google")
+ return jsonify({"error": "failed to create user"}), 500
+
+ log.info("user_registered", user_id=user_id, auth_method="google_oauth")
+
+ # Send welcome email
+ email_service.send_welcome_email(
+ provider_info["email"], provider_info.get("name")
+ )
+
+ # Generate tokens
+ auth_method = OAuthProviders.GOOGLE
+ access_token = generate_access_jwt(user_id, True, auth_method)
+ refresh_token = generate_refresh_jwt(user_id, True, auth_method)
+
+ # Set tokens in cookies and redirect
+ resp = redirect("/dashboard")
+ set_refresh_cookie(resp, refresh_token)
+ set_access_cookie(resp, access_token)
+ return resp
+
+ except Exception as e:
+ log.error(
+ "oauth_callback_failed",
+ provider="google",
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return jsonify({"error": "OAuth authentication failed"}), 500
+
+
+@oauth_bp.route("/google/link", methods=["GET"])
+@requires_auth
+@limiter.limit("5/minute")
+def oauth_google_link():
+ """Link Google OAuth to existing account"""
+ google = providers.get("google")
+ if not google:
+ log.error("oauth_provider_not_configured", provider="google")
+ return jsonify({"error": "Google OAuth not configured"}), 500
+
+ # Check if user already has Google linked
+ current_user = get_user_by_id(g.user_id)
+ if not current_user:
+ return jsonify({"error": "user not found"}), 404
+
+ auth_providers = current_user.get("auth_providers", [])
+ for provider_entry in auth_providers:
+ if provider_entry.get("provider") == OAuthProviders.GOOGLE:
+ return jsonify({"error": "Google account already linked"}), 409
+
+ # Generate state parameter for CSRF protection with user ID embedded securely
+ state = generate_oauth_state(OAuthProviders.GOOGLE, "link", user_id=str(g.user_id))
+
+ # Get redirect URI (same as login)
+ redirect_uri = get_oauth_redirect_url(OAuthProviders.GOOGLE)
+
+ # Redirect to Google OAuth
+ return google.authorize_redirect(redirect_uri, state=state)
+
+
+@oauth_bp.route("/github", methods=["GET"])
+@limiter.limit("10/minute")
+def oauth_github_login():
+ """Initiate GitHub OAuth login"""
+ github = providers.get("github")
+ if not github:
+ log.error("oauth_provider_not_configured", provider="github")
+ return jsonify({"error": "GitHub OAuth not configured"}), 500
+
+ # Generate state parameter for CSRF protection
+ state = generate_oauth_state(OAuthProviders.GITHUB, "login")
+
+ # Get redirect URI
+ redirect_uri = get_oauth_redirect_url(OAuthProviders.GITHUB)
+
+ # Redirect to GitHub OAuth
+ return github.authorize_redirect(redirect_uri, state=state)
+
+
+@oauth_bp.route("/github/callback", methods=["GET"])
+@limiter.limit("20/minute")
+def oauth_github_callback():
+ """Handle GitHub OAuth callback"""
+ github = providers.get("github")
+ if not github:
+ log.error("oauth_provider_not_configured", provider="github")
+ return jsonify({"error": "GitHub OAuth not configured"}), 500
+
+ # Verify state parameter
+ state = request.args.get("state")
+ if not state:
+ log.warning("oauth_state_missing", provider="github")
+ return jsonify({"error": "missing state parameter"}), 400
+
+ is_valid, state_data = verify_oauth_state(state, OAuthProviders.GITHUB)
+ if not is_valid:
+ log.warning("oauth_state_invalid", provider="github")
+ return jsonify({"error": "invalid state parameter"}), 400
+
+ # Check for error from OAuth provider
+ error = request.args.get("error")
+ if error:
+ error_description = request.args.get(
+ "error_description", "OAuth authorization failed"
+ )
+ log.warning(
+ "oauth_provider_error",
+ provider="github",
+ error=error,
+ description=error_description,
+ )
+ return jsonify({"error": f"OAuth error: {error_description}"}), 400
+
+ try:
+ # Exchange authorization code for token
+ token = github.authorize_access_token()
+
+ # Get user info from GitHub
+ resp = github.get("user", token=token)
+ userinfo = resp.json()
+
+ # Get user emails from GitHub
+ emails_resp = github.get("user/emails", token=token)
+ email_data = emails_resp.json()
+
+ # Extract standardized user info
+ provider_info = extract_user_info_from_github(userinfo, email_data)
+
+ if not provider_info["email"]:
+ return jsonify({"error": "email not provided by OAuth provider"}), 400
+
+ # Check if this is a linking action
+ action = state_data.get("action", "login")
+
+ if action == "link":
+ # This is an account linking request
+ link_user_id = state_data.get("user_id")
+ if not link_user_id:
+ return jsonify({"error": "invalid linking request"}), 400
+
+ # Verify user exists
+ current_user = get_user_by_id(link_user_id)
+ if not current_user:
+ return jsonify({"error": "user not found"}), 404
+
+ # Check if provider is already linked to this user
+ auth_providers = current_user.get("auth_providers", [])
+ for provider_entry in auth_providers:
+ if provider_entry.get("provider") == OAuthProviders.GITHUB:
+ return jsonify({"error": "GitHub account already linked"}), 409
+
+ # Check if this GitHub account is already linked to another user
+ existing_oauth_user = find_user_by_provider(
+ OAuthProviders.GITHUB, provider_info["provider_user_id"]
+ )
+ if existing_oauth_user and str(existing_oauth_user["_id"]) != link_user_id:
+ return jsonify(
+ {"error": "This GitHub account is already linked to another user"}
+ ), 409
+
+ # Verify that the OAuth email matches the current user's email
+ if current_user.get("email", "").lower() != provider_info["email"].lower():
+ log.warning(
+ "oauth_email_mismatch",
+ user_id=link_user_id,
+ provider="github",
+ reason="linking_attempt",
+ )
+ return jsonify(
+ {
+ "error": "email mismatch",
+ "message": f"The email associated with this GitHub account ({provider_info['email']}) does not match your account email ({current_user.get('email', '')}). Please use a GitHub account with the same email address.",
+ }
+ ), 400
+
+ # Link the provider to current user
+ if link_provider_to_user(
+ link_user_id, provider_info, OAuthProviders.GITHUB
+ ):
+ log.info(
+ "oauth_account_linked", user_id=link_user_id, provider="github"
+ )
+
+ # Generate tokens for the linked user
+ auth_method = OAuthProviders.GITHUB
+ access_token = generate_access_jwt(link_user_id, True, auth_method)
+ refresh_token = generate_refresh_jwt(link_user_id, True, auth_method)
+
+ # Set tokens in cookies and redirect
+ resp = redirect("/dashboard")
+ set_refresh_cookie(resp, refresh_token)
+ set_access_cookie(resp, access_token)
+ return resp
+ else:
+ log.error(
+ "oauth_linking_failed",
+ user_id=link_user_id,
+ provider="github",
+ reason="database_error",
+ )
+ return jsonify({"error": "failed to link GitHub account"}), 500
+
+ # Check if user already exists with this OAuth provider
+ existing_oauth_user = find_user_by_provider(
+ OAuthProviders.GITHUB, provider_info["provider_user_id"]
+ )
+
+ if existing_oauth_user:
+ # User exists with this OAuth provider - log them in
+ user_id = str(existing_oauth_user["_id"])
+ update_user_last_login(user_id)
+
+ log.info(
+ "oauth_login_success",
+ user_id=user_id,
+ provider="github",
+ action="login",
+ )
+
+ # Generate tokens
+ auth_method = OAuthProviders.GITHUB
+ access_token = generate_access_jwt(user_id, True, auth_method)
+ refresh_token = generate_refresh_jwt(user_id, True, auth_method)
+
+ # Set tokens in cookies and redirect
+ resp = redirect("/dashboard")
+ set_refresh_cookie(resp, refresh_token)
+ set_access_cookie(resp, access_token)
+ return resp
+
+ # Check if user exists with the same email
+ existing_email_user = get_user_by_email(provider_info["email"])
+
+ if existing_email_user:
+ # User exists with same email - check if we can auto-link
+ if can_auto_link_accounts(
+ existing_email_user, provider_info, OAuthProviders.GITHUB
+ ):
+ # Auto-link the accounts
+ user_id = str(existing_email_user["_id"])
+
+ if link_provider_to_user(user_id, provider_info, OAuthProviders.GITHUB):
+ update_user_last_login(user_id)
+
+ log.info("oauth_auto_linked", user_id=user_id, provider="github")
+
+ # Generate tokens
+ auth_method = OAuthProviders.GITHUB
+ access_token = generate_access_jwt(user_id, True, auth_method)
+ refresh_token = generate_refresh_jwt(user_id, True, auth_method)
+
+ # Set tokens in cookies and redirect
+ resp = redirect("/dashboard")
+ set_refresh_cookie(resp, refresh_token)
+ set_access_cookie(resp, access_token)
+ return resp
+ else:
+ log.error(
+ "oauth_auto_link_failed", user_id=user_id, provider="github"
+ )
+ return jsonify({"error": "failed to link accounts"}), 500
+ else:
+ # Cannot auto-link - require manual account linking or different email
+ return jsonify(
+ {
+ "error": "email already exists",
+ "message": "An account with this email already exists. Please log in with your existing method first to link accounts.",
+ }
+ ), 409
+
+ # Create new user with OAuth
+ user_id = create_oauth_user(provider_info, OAuthProviders.GITHUB)
+
+ if not user_id:
+ log.error("oauth_user_creation_failed", provider="github")
+ return jsonify({"error": "failed to create user"}), 500
+
+ log.info("user_registered", user_id=user_id, auth_method="github_oauth")
+
+ # Send welcome email
+ email_service.send_welcome_email(
+ provider_info["email"], provider_info.get("name")
+ )
+
+ # Generate tokens
+ auth_method = OAuthProviders.GITHUB
+ access_token = generate_access_jwt(user_id, True, auth_method)
+ refresh_token = generate_refresh_jwt(user_id, True, auth_method)
+
+ # Set tokens in cookies and redirect
+ resp = redirect("/dashboard")
+ set_refresh_cookie(resp, refresh_token)
+ set_access_cookie(resp, access_token)
+ return resp
+
+ except Exception as e:
+ log.error(
+ "oauth_callback_failed",
+ provider="github",
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return jsonify({"error": "OAuth authentication failed"}), 500
+
+
+@oauth_bp.route("/github/link", methods=["GET"])
+@requires_auth
+@limiter.limit("5/minute")
+def oauth_github_link():
+ """Link GitHub OAuth to existing account"""
+ github = providers.get("github")
+ if not github:
+ log.error("oauth_provider_not_configured", provider="github")
+ return jsonify({"error": "GitHub OAuth not configured"}), 500
+
+ # Check if user already has GitHub linked
+ current_user = get_user_by_id(g.user_id)
+ if not current_user:
+ return jsonify({"error": "user not found"}), 404
+
+ auth_providers = current_user.get("auth_providers", [])
+ for provider_entry in auth_providers:
+ if provider_entry.get("provider") == OAuthProviders.GITHUB:
+ return jsonify({"error": "GitHub account already linked"}), 409
+
+ # Generate state parameter for CSRF protection with user ID embedded securely
+ state = generate_oauth_state(OAuthProviders.GITHUB, "link", user_id=str(g.user_id))
+
+ # Get redirect URI (same as login)
+ redirect_uri = get_oauth_redirect_url(OAuthProviders.GITHUB)
+
+ # Redirect to GitHub OAuth
+ return github.authorize_redirect(redirect_uri, state=state)
+
+
+@oauth_bp.route("/discord", methods=["GET"])
+@limiter.limit("10/minute")
+def oauth_discord_login():
+ """Initiate Discord OAuth login"""
+ discord = providers.get("discord")
+ if not discord:
+ log.error("oauth_provider_not_configured", provider="discord")
+ return jsonify({"error": "Discord OAuth not configured"}), 500
+
+ # Generate state parameter for CSRF protection
+ state = generate_oauth_state(OAuthProviders.DISCORD, "login")
+
+ # Get redirect URI
+ redirect_uri = get_oauth_redirect_url(OAuthProviders.DISCORD)
+
+ # Redirect to Discord OAuth
+ return discord.authorize_redirect(redirect_uri, state=state)
+
+
+@oauth_bp.route("/discord/callback", methods=["GET"])
+@limiter.limit("20/minute")
+def oauth_discord_callback():
+ """Handle Discord OAuth callback"""
+ discord = providers.get("discord")
+ if not discord:
+ log.error("oauth_provider_not_configured", provider="discord")
+ return jsonify({"error": "Discord OAuth not configured"}), 500
+
+ # Verify state parameter
+ state = request.args.get("state")
+ if not state:
+ log.warning("oauth_state_missing", provider="discord")
+ return jsonify({"error": "missing state parameter"}), 400
+
+ is_valid, state_data = verify_oauth_state(state, OAuthProviders.DISCORD)
+ if not is_valid:
+ log.warning("oauth_state_invalid", provider="discord")
+ return jsonify({"error": "invalid state parameter"}), 400
+
+ # Check for error from OAuth provider
+ error = request.args.get("error")
+ if error:
+ error_description = request.args.get(
+ "error_description", "OAuth authorization failed"
+ )
+ log.warning(
+ "oauth_provider_error",
+ provider="discord",
+ error=error,
+ description=error_description,
+ )
+ return jsonify({"error": f"OAuth error: {error_description}"}), 400
+
+ try:
+ # Exchange authorization code for token
+ token = discord.authorize_access_token()
+
+ # Get user info from Discord
+ resp = discord.get("users/@me", token=token)
+ userinfo = resp.json()
+
+ # Extract standardized user info
+ provider_info = extract_user_info_from_discord(userinfo)
+
+ if not provider_info["email"]:
+ return jsonify({"error": "email not provided by OAuth provider"}), 400
+
+ # Check if this is a linking action
+ action = state_data.get("action", "login")
+
+ if action == "link":
+ # This is an account linking request
+ link_user_id = state_data.get("user_id")
+ if not link_user_id:
+ return jsonify({"error": "invalid linking request"}), 400
+
+ # Verify user exists
+ current_user = get_user_by_id(link_user_id)
+ if not current_user:
+ return jsonify({"error": "user not found"}), 404
+
+ # Check if provider is already linked to this user
+ auth_providers = current_user.get("auth_providers", [])
+ for provider_entry in auth_providers:
+ if provider_entry.get("provider") == OAuthProviders.DISCORD:
+ return jsonify({"error": "Discord account already linked"}), 409
+
+ # Check if this Discord account is already linked to another user
+ existing_oauth_user = find_user_by_provider(
+ OAuthProviders.DISCORD, provider_info["provider_user_id"]
+ )
+ if existing_oauth_user and str(existing_oauth_user["_id"]) != link_user_id:
+ return jsonify(
+ {"error": "This Discord account is already linked to another user"}
+ ), 409
+
+ # Verify that the OAuth email matches the current user's email
+ if current_user.get("email", "").lower() != provider_info["email"].lower():
+ log.warning(
+ "oauth_email_mismatch",
+ user_id=link_user_id,
+ provider="discord",
+ reason="linking_attempt",
+ )
+ return jsonify(
+ {
+ "error": "email mismatch",
+ "message": f"The email associated with this Discord account ({provider_info['email']}) does not match your account email ({current_user.get('email', '')}). Please use a Discord account with the same email address.",
+ }
+ ), 400
+
+ # Link the provider to current user
+ if link_provider_to_user(
+ link_user_id, provider_info, OAuthProviders.DISCORD
+ ):
+ log.info(
+ "oauth_account_linked", user_id=link_user_id, provider="discord"
+ )
+
+ # Generate tokens for the linked user
+ auth_method = OAuthProviders.DISCORD
+ access_token = generate_access_jwt(link_user_id, True, auth_method)
+ refresh_token = generate_refresh_jwt(link_user_id, True, auth_method)
+
+ # Set tokens in cookies and redirect
+ resp = redirect("/dashboard")
+ set_refresh_cookie(resp, refresh_token)
+ set_access_cookie(resp, access_token)
+ return resp
+ else:
+ log.error(
+ "oauth_linking_failed",
+ user_id=link_user_id,
+ provider="discord",
+ reason="database_error",
+ )
+ return jsonify({"error": "failed to link Discord account"}), 500
+
+ # Check if user already exists with this OAuth provider
+ existing_oauth_user = find_user_by_provider(
+ OAuthProviders.DISCORD, provider_info["provider_user_id"]
+ )
+
+ if existing_oauth_user:
+ # User exists with this OAuth provider - log them in
+ user_id = str(existing_oauth_user["_id"])
+ update_user_last_login(user_id)
+
+ log.info(
+ "oauth_login_success",
+ user_id=user_id,
+ provider="discord",
+ action="login",
+ )
+
+ # Generate tokens
+ auth_method = OAuthProviders.DISCORD
+ access_token = generate_access_jwt(user_id, True, auth_method)
+ refresh_token = generate_refresh_jwt(user_id, True, auth_method)
+
+ # Set tokens in cookies and redirect
+ resp = redirect("/dashboard")
+ set_refresh_cookie(resp, refresh_token)
+ set_access_cookie(resp, access_token)
+ return resp
+
+ # Check if user exists with the same email
+ existing_email_user = get_user_by_email(provider_info["email"])
+
+ if existing_email_user:
+ # User exists with same email - check if we can auto-link
+ if can_auto_link_accounts(
+ existing_email_user, provider_info, OAuthProviders.DISCORD
+ ):
+ # Auto-link the accounts
+ user_id = str(existing_email_user["_id"])
+
+ if link_provider_to_user(
+ user_id, provider_info, OAuthProviders.DISCORD
+ ):
+ update_user_last_login(user_id)
+
+ log.info("oauth_auto_linked", user_id=user_id, provider="discord")
+
+ # Generate tokens
+ auth_method = OAuthProviders.DISCORD
+ access_token = generate_access_jwt(user_id, True, auth_method)
+ refresh_token = generate_refresh_jwt(user_id, True, auth_method)
+
+ # Set tokens in cookies and redirect
+ resp = redirect("/dashboard")
+ set_refresh_cookie(resp, refresh_token)
+ set_access_cookie(resp, access_token)
+ return resp
+ else:
+ log.error(
+ "oauth_auto_link_failed", user_id=user_id, provider="discord"
+ )
+ return jsonify({"error": "failed to link accounts"}), 500
+ else:
+ # Cannot auto-link - require manual account linking or different email
+ return jsonify(
+ {
+ "error": "email already exists",
+ "message": "An account with this email already exists. Please log in with your existing method first to link accounts.",
+ }
+ ), 409
+
+ # Create new user with OAuth
+ user_id = create_oauth_user(provider_info, OAuthProviders.DISCORD)
+
+ if not user_id:
+ log.error("oauth_user_creation_failed", provider="discord")
+ return jsonify({"error": "failed to create user"}), 500
+
+ log.info("user_registered", user_id=user_id, auth_method="discord_oauth")
+
+ # Send welcome email
+ email_service.send_welcome_email(
+ provider_info["email"], provider_info.get("name")
+ )
+
+ # Generate tokens
+ auth_method = OAuthProviders.DISCORD
+ access_token = generate_access_jwt(user_id, True, auth_method)
+ refresh_token = generate_refresh_jwt(user_id, True, auth_method)
+
+ # Set tokens in cookies and redirect
+ resp = redirect("/dashboard")
+ set_refresh_cookie(resp, refresh_token)
+ set_access_cookie(resp, access_token)
+ return resp
+
+ except Exception as e:
+ log.error(
+ "oauth_callback_failed",
+ provider="discord",
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return jsonify({"error": "OAuth authentication failed"}), 500
+
+
+@oauth_bp.route("/discord/link", methods=["GET"])
+@requires_auth
+@limiter.limit("5/minute")
+def oauth_discord_link():
+ """Link Discord OAuth to existing account"""
+ discord = providers.get("discord")
+ if not discord:
+ log.error("oauth_provider_not_configured", provider="discord")
+ return jsonify({"error": "Discord OAuth not configured"}), 500
+
+ # Check if user already has Discord linked
+ current_user = get_user_by_id(g.user_id)
+ if not current_user:
+ return jsonify({"error": "user not found"}), 404
+
+ auth_providers = current_user.get("auth_providers", [])
+ for provider_entry in auth_providers:
+ if provider_entry.get("provider") == OAuthProviders.DISCORD:
+ return jsonify({"error": "Discord account already linked"}), 409
+
+ # Generate state parameter for CSRF protection with user ID embedded securely
+ state = generate_oauth_state(OAuthProviders.DISCORD, "link", user_id=str(g.user_id))
+
+ # Get redirect URI (same as login)
+ redirect_uri = get_oauth_redirect_url(OAuthProviders.DISCORD)
+
+ # Redirect to Discord OAuth
+ return discord.authorize_redirect(redirect_uri, state=state)
+
+
+@oauth_bp.route("/providers", methods=["GET"])
+@requires_auth
+def list_auth_providers():
+ """List all linked OAuth providers for the current user"""
+ user = get_user_by_id(g.user_id)
+ if not user:
+ return jsonify({"error": "user not found"}), 404
+
+ providers = []
+ for provider in user.get("auth_providers", []):
+ providers.append(
+ {
+ "provider": provider.get("provider"),
+ "email": provider.get("email"),
+ "email_verified": provider.get("email_verified", False),
+ "linked_at": provider.get("linked_at").isoformat()
+ if provider.get("linked_at")
+ else None,
+ "profile": {
+ "name": provider.get("profile", {}).get("name"),
+ "picture": provider.get("profile", {}).get("picture"),
+ },
+ }
+ )
+
+ return jsonify(
+ {"providers": providers, "password_set": user.get("password_set", False)}
+ )
+
+
+@oauth_bp.route("/providers//unlink", methods=["DELETE"])
+@requires_auth
+@limiter.limit("5/minute")
+def unlink_oauth_provider(provider):
+ """Unlink an OAuth provider from the current user"""
+ user = get_user_by_id(g.user_id)
+ if not user:
+ return jsonify({"error": "user not found"}), 404
+
+ # Check if user has password or other providers
+ auth_providers = user.get("auth_providers", [])
+ has_password = user.get("password_set", False)
+
+ # Count providers after removing this one
+ remaining_providers = [p for p in auth_providers if p.get("provider") != provider]
+
+ if not has_password and len(remaining_providers) == 0:
+ return jsonify(
+ {
+ "error": "cannot unlink last authentication method",
+ "message": "Set a password first before unlinking your last OAuth provider",
+ }
+ ), 400
+
+ # Remove the provider
+ try:
+ from bson import ObjectId
+
+ result = users_collection.update_one(
+ {"_id": ObjectId(g.user_id)},
+ {
+ "$pull": {"auth_providers": {"provider": provider}},
+ "$set": {"updated_at": datetime.now(timezone.utc)},
+ },
+ )
+
+ if result.modified_count > 0:
+ log.info("oauth_provider_unlinked", user_id=g.user_id, provider=provider)
+ return jsonify(
+ {"success": True, "message": f"{provider} unlinked successfully"}
+ )
+ else:
+ log.warning("oauth_unlink_not_found", user_id=g.user_id, provider=provider)
+ return jsonify({"error": "provider not found or already unlinked"}), 404
+
+ except Exception as e:
+ log.error(
+ "oauth_unlink_failed",
+ user_id=g.user_id,
+ provider=provider,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return jsonify({"error": "failed to unlink provider"}), 500
diff --git a/blueprints/redirector.py b/blueprints/redirector.py
index 4fc27ca0..f047a79f 100644
--- a/blueprints/redirector.py
+++ b/blueprints/redirector.py
@@ -1,4 +1,11 @@
import time
+from bson import ObjectId
+from ua_parser import parse
+from datetime import datetime, timezone
+from urllib.parse import unquote
+import re
+import tldextract
+from crawlerdetect import CrawlerDetect
from flask import (
Blueprint,
request,
@@ -9,26 +16,26 @@
from utils.url_utils import (
BOT_USER_AGENTS,
get_country,
+ get_city,
get_client_ip,
- validate_emoji_alias,
+ get_city_cf,
)
from utils.mongo_utils import (
- load_url,
update_url,
- load_emoji_url,
update_emoji_url,
+ get_url_by_length_and_type,
+ update_url_v2_clicks,
+ expire_url_if_max_clicks_reached,
+ insert_click_data,
)
+from utils.auth_utils import verify_password
+from utils.logger import get_logger, hash_ip, should_sample
from cache import cache_query as cq
-from cache.cache_url import UrlData
+from cache.cache_url import UrlCacheData
from .limiter import limiter
-from ua_parser import parse
-from datetime import datetime, timezone
-from urllib.parse import unquote
-import re
-import tldextract
-from crawlerdetect import CrawlerDetect
+log = get_logger(__name__)
crawler_detect = CrawlerDetect()
tld_no_cache_extract = tldextract.TLDExtract(cache_dir=None)
@@ -36,71 +43,180 @@
url_redirector = Blueprint("url_redirector", __name__)
+class RedirectorError(Exception):
+ """Base error for redirector responses."""
+
+ status_code = 500
+ default_message = "Internal server error"
+
+ def __init__(self, message: str | None = None):
+ self.message = message or self.default_message
+ super().__init__(self.message)
+
+ def _json_error_response(self, status_code: int, message: str):
+ """Return a standardized JSON error response."""
+ return (
+ jsonify(
+ {
+ "error_code": str(status_code),
+ "error_message": message,
+ "host_url": request.host_url,
+ }
+ ),
+ status_code,
+ )
+
+ def to_response(self):
+ return self._json_error_response(self.status_code, self.message)
+
+
+class BadRequestError(RedirectorError):
+ status_code = 400
+ default_message = "Invalid request"
+
+
+class ForbiddenError(RedirectorError):
+ status_code = 403
+ default_message = "Access denied"
+
+
+class InternalRedirectorError(RedirectorError):
+ status_code = 500
+
+
@url_redirector.route("/", methods=["GET"])
@limiter.exempt
def redirect_url(short_code):
user_ip = get_client_ip()
- projection = {
- "_id": 1,
- "url": 1,
- "password": 1,
- "max-clicks": 1,
- "expiration-time": 1,
- "total-clicks": 1,
- "ips": {"$elemMatch": {"$eq": user_ip}},
- "block-bots": 1,
- "average_redirection_time": 1,
- }
-
short_code = unquote(short_code)
-
- is_emoji = False
-
- # Measure redirection time
start_time = time.perf_counter()
- cached_url_data = cq.get_url_data(short_code)
+ # Try to get URL data from cache first (new cache schema)
+ cached_url_data = cq.get_url_cache_data(short_code)
+
if cached_url_data:
url_data = {
- "url": cached_url_data.url,
- "password": cached_url_data.password,
- "block-bots": cached_url_data.block_bots,
+ "_id": cached_url_data._id,
+ "url": cached_url_data.long_url,
+ "long_url": cached_url_data.long_url,
+ "password": cached_url_data.password_hash,
+ "block_bots": cached_url_data.block_bots,
+ "expiration-time": cached_url_data.expiration_time,
+ "expire_after": cached_url_data.expiration_time,
+ "status": cached_url_data.url_status,
+ "max_clicks": cached_url_data.max_clicks,
+ "owner_id": cached_url_data.owner_id,
}
+ schema_type = cached_url_data.schema_version
+
+ if schema_type == "v2":
+ url_data["_id"] = ObjectId(cached_url_data._id)
+ url_data["owner_id"] = (
+ ObjectId(cached_url_data.owner_id) if cached_url_data.owner_id else None
+ )
+ if (
+ url_data["max_clicks"] is not None
+ and type(url_data["max_clicks"]) is not int
+ ):
+ url_data["max_clicks"] = int(url_data["max_clicks"])
+
+ is_emoji = False
else:
- if validate_emoji_alias(short_code):
- is_emoji = True
- url_data = load_emoji_url(short_code, projection)
- else:
- url_data = load_url(short_code, projection)
-
- if url_data and not url_data.get(
- "max-clicks", 0
- ): # skip caching if max-clicks is set (will break if url has high max-clicks)
- cq.set_url_data(
- short_code,
- UrlData(
- url=url_data["url"],
- short_code=short_code,
- password=url_data.get("password"),
- block_bots=url_data.get("block-bots", False),
+ # Determine URL schema and fetch data
+ url_data, schema_type = get_url_by_length_and_type(short_code)
+ is_emoji = schema_type == "emoji"
+
+ if not url_data:
+ log.warning("url_not_found", short_code=short_code)
+ return (
+ render_template(
+ "error.html",
+ error_code="404",
+ error_message="URL NOT FOUND",
+ host_url=request.host_url,
),
+ 404,
)
- if not url_data:
- return (
- render_template(
- "error.html",
- error_code="404",
- error_message="URL NOT FOUND",
- host_url=request.host_url,
- ),
- 404,
- )
+ # Cache the URL data (but only if it should be cached)
+ # For v2 URLs, check status and don't cache if blocked/expired/inactive
+ if schema_type == "v2":
+ status = url_data.get("status", "ACTIVE")
+ if status in ["BLOCKED", "EXPIRED", "INACTIVE"]:
+ # Cache minimal data for blocked/expired/inactive URLs
+ minimal_cache = UrlCacheData(
+ _id=str(url_data["_id"]),
+ alias=short_code,
+ long_url="",
+ block_bots=False,
+ password_hash=None,
+ expiration_time=None,
+ max_clicks=None,
+ url_status=status,
+ schema_version="v2",
+ owner_id=str(url_data.get("owner_id"))
+ if url_data.get("owner_id")
+ else None,
+ )
+ cq.set_url_cache_data(short_code, minimal_cache)
+ else:
+ cache_data = UrlCacheData(
+ _id=str(url_data["_id"]),
+ alias=short_code,
+ long_url=url_data["long_url"],
+ block_bots=url_data.get("block_bots", False),
+ password_hash=url_data.get("password"),
+ expiration_time=url_data.get("expire_after"),
+ max_clicks=url_data.get("max_clicks"),
+ url_status=status,
+ schema_version="v2",
+ owner_id=str(url_data.get("owner_id"))
+ if url_data.get("owner_id")
+ else None,
+ )
+ cq.set_url_cache_data(short_code, cache_data)
+ elif schema_type == "v1":
+ # Cache old schema URLs without max-clicks
+ if not url_data.get("max-clicks"):
+ cache_data = UrlCacheData(
+ _id=short_code, # For v1, _id is the short_code
+ alias=short_code,
+ long_url=url_data["url"],
+ block_bots=url_data.get("block_bots", False),
+ password_hash=url_data.get("password"),
+ expiration_time=url_data.get("expiration-time"),
+ max_clicks=url_data.get("max-clicks"),
+ url_status="ACTIVE", # v1 URLs don't have status field
+ schema_version="v1",
+ owner_id=None, # v1 URLs don't have owner_id
+ )
+ cq.set_url_cache_data(short_code, cache_data)
- url = url_data["url"]
+ # Handle blocked/expired/inactive URLs for v2 schema
+ if schema_type == "v2":
+ status = url_data.get("status", "ACTIVE")
+ if status in ["BLOCKED", "EXPIRED", "INACTIVE"]:
+ return (
+ render_template(
+ "error.html",
+ error_code="403" if status == "BLOCKED" else "400",
+ error_message="ACCESS DENIED"
+ if status == "BLOCKED"
+ else "SHORT URL EXPIRED",
+ host_url=request.host_url,
+ ),
+ 403 if status == "BLOCKED" else 400,
+ )
+
+ # Get the URL to redirect to
+ if schema_type == "v2":
+ url = url_data["long_url"]
+ else:
+ url = url_data["url"]
- if "max-clicks" in url_data:
- if int(url_data["total-clicks"]) >= int(url_data["max-clicks"]):
+ # Check max clicks for old schema
+ if schema_type == "v1" and "max-clicks" in url_data:
+ if int(url_data.get("total-clicks", 0)) >= int(url_data["max-clicks"]):
return (
render_template(
"error.html",
@@ -111,9 +227,21 @@ def redirect_url(short_code):
400,
)
- if "password" in url_data:
+ # Check password protection
+ if "password" in url_data and url_data["password"]:
password = request.values.get("password")
- if password != url_data["password"]:
+
+ # Use different password verification logic based on schema type
+ password_valid = False
+ if schema_type == "v2":
+ # For v2 URLs, use verify_password for hashed passwords
+ password_valid = verify_password(password or "", url_data["password"])
+ else:
+ # For v1 URLs, use direct string comparison
+ password_valid = password == url_data["password"]
+
+ if not password_valid:
+ log.info("password_required", short_code=short_code, schema=schema_type)
return (
render_template(
"password.html", short_code=short_code, host_url=request.host_url
@@ -121,171 +249,366 @@ def redirect_url(short_code):
401,
)
- # store the device and browser information
- user_agent = request.headers.get("User-Agent")
- if not user_agent:
- return jsonify(
- {
- "error_code": "400",
- "error_message": "Invalid User-Agent",
- "host_url": request.host_url,
- }
- ), 400
+ # Process the click and track analytics
+ # For HEAD and OPTIONS, skip analytics and just redirect
+ if request.method in ("HEAD", "OPTIONS"):
+ pass # Do not log click, just proceed to redirect
+ else:
+ try:
+ process_url_click(
+ url_data, short_code, schema_type, is_emoji, user_ip, start_time
+ )
+ except RedirectorError as exc:
+ log.error(
+ "click_processing_failed",
+ short_code=short_code,
+ schema=schema_type,
+ error=exc.message,
+ error_type=type(exc).__name__,
+ )
+ return exc.to_response()
+
+ # Sample redirect events (5% sampling)
+ if should_sample("url_redirect"):
+ redirect_ms = int((time.perf_counter() - start_time) * 1000)
+ user_agent = request.headers.get("User-Agent", "")
+ is_bot = crawler_detect.isCrawler(user_agent) or any(
+ re.search(bot, user_agent, re.IGNORECASE) for bot in BOT_USER_AGENTS
+ )
- try:
- ua = parse(user_agent)
- if not ua or not ua.user_agent or not ua.os:
- return jsonify(
- {
- "error_code": "400",
- "error_message": "Invalid User-Agent",
- "host_url": request.host_url,
- }
- ), 400
- except Exception:
- return jsonify(
- {
- "error_code": "400",
- "error_message": "An internal error occurred while processing the User-Agent",
- "host_url": request.host_url,
- }
- ), 400
-
- os_name = ua.os.family
- browser = ua.user_agent.family
- referrer = request.headers.get("Referer")
- country = get_country(user_ip)
-
- is_unique_click = url_data.get("ips", None) is None
-
- if country:
- country = country.replace(".", " ")
-
- updates = {"$inc": {}, "$set": {}, "$addToSet": {}}
-
- if "ips" not in url_data:
- url_data["ips"] = []
-
- if referrer:
- referrer_raw = tld_no_cache_extract(referrer)
- referrer = (
- f"{referrer_raw.domain}.{referrer_raw.suffix}"
- if referrer_raw.suffix
- else referrer_raw.domain
+ log.info(
+ "url_redirect",
+ short_code=short_code,
+ schema=schema_type,
+ redirect_ms=redirect_ms,
+ is_bot=is_bot,
+ ip_hash=hash_ip(user_ip),
)
- sanitized_referrer = re.sub(r"[.$\x00-\x1F\x7F-\x9F]", "_", referrer)
-
- updates["$inc"][f"referrer.{sanitized_referrer}.counts"] = 1
- updates["$addToSet"][f"referrer.{sanitized_referrer}.ips"] = user_ip
-
- updates["$inc"][f"country.{country}.counts"] = 1
- updates["$addToSet"][f"country.{country}.ips"] = user_ip
-
- updates["$inc"][f"browser.{browser}.counts"] = 1
- updates["$addToSet"][f"browser.{browser}.ips"] = user_ip
-
- updates["$inc"][f"os_name.{os_name}.counts"] = 1
- updates["$addToSet"][f"os_name.{os_name}.ips"] = user_ip
-
- for bot in BOT_USER_AGENTS:
- bot_re = re.compile(bot, re.IGNORECASE)
- if bot_re.search(user_agent):
- if url_data.get("block-bots", False):
- return (
- jsonify(
- {
- "error_code": "403",
- "error_message": "Access Denied, Bots not allowed",
- "host_url": request.host_url,
- }
- ),
- 403,
- )
- sanitized_bot = re.sub(r"[.$\x00-\x1F\x7F-\x9F]", "_", bot)
- updates["$inc"][f"bots.{sanitized_bot}"] = 1
- break
- else:
- if crawler_detect.isCrawler(user_agent):
- if url_data.get("block-bots", False):
- return (
- jsonify(
- {
- "error_code": "403",
- "error_message": "Access Denied, Bots not allowed",
- "host_url": request.host_url,
- }
- ),
- 403,
- )
- updates["$inc"][f"bots.{crawler_detect.getMatches()}"] = 1
- # increment the counter for the short code
- today = str(datetime.now()).split()[0]
- updates["$inc"][f"counter.{today}"] = 1
+ redirect_response = redirect(url, code=302)
+ redirect_response.headers["X-Robots-Tag"] = "noindex, nofollow"
- if is_unique_click:
- updates["$inc"][f"unique_counter.{today}"] = 1
+ return redirect_response
- updates["$addToSet"]["ips"] = user_ip
- updates["$inc"]["total-clicks"] = 1
+def process_url_click(
+ url_data,
+ short_code,
+ schema_type,
+ is_emoji,
+ user_ip,
+ start_time,
+):
+ """Process click tracking and analytics for both v1 and v2 schemas"""
+ try:
+ if schema_type == "v2":
+ handle_v2_click(url_data, short_code, user_ip, start_time)
+ else:
+ handle_legacy_click(url_data, short_code, is_emoji, user_ip, start_time)
+ except RedirectorError:
+ raise
+ except Exception as e:
+ print(f"Error processing click for {short_code}: {e}")
+ raise InternalRedirectorError() from e
- updates["$set"]["last-click"] = str(
- datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
- )
- updates["$set"]["last-click-browser"] = browser
- updates["$set"]["last-click-os"] = os_name
- updates["$set"]["last-click-country"] = country
- # Calculate redirection time
- end_time = time.perf_counter()
- redirection_time = (end_time - start_time) * 1000
+def handle_v2_click(url_data, short_code, user_ip, start_time):
+ """Handle click tracking for new v2 schema URLs"""
+ try:
+ # Get user agent info
+ user_agent = request.headers.get("User-Agent", "")
+ if not user_agent:
+ raise BadRequestError("Invalid User-Agent")
+
+ try:
+ ua = parse(user_agent)
+ except Exception:
+ raise BadRequestError(
+ "An internal error occurred while processing the User-Agent"
+ )
+
+ if not ua or not ua.user_agent or not ua.os:
+ raise BadRequestError("Invalid User-Agent")
+
+ os_name = ua.os.family
+ browser = ua.user_agent.family
+ # device = ua.device.family if ua.device else "Unknown"
+
+ referrer = request.headers.get("Referer")
+ sanitized_referrer_domain = None
+
+ # parse the referrer
+ if referrer:
+ referrer_domain = tld_no_cache_extract(referrer)
+ referrer_domain = (
+ f"{referrer_domain.domain}.{referrer_domain.suffix}"
+ if referrer_domain.suffix
+ else referrer_domain.domain
+ )
+ # First, replace any control characters, special characters like '$', and non-printable ASCII with underscores
+ sanitized_referrer_domain = re.sub(
+ r"[$\x00-\x1F\x7F-\x9F]", "_", referrer_domain
+ )
+ # Then, replace any character not in the allowed set [a-zA-Z0-9.-] with underscores for extra safety
+ sanitized_referrer_domain = re.sub(
+ r"[^a-zA-Z0-9.-]", "_", sanitized_referrer_domain
+ )
- curr_avg = url_data.get("average_redirection_time", 0)
+ country = get_country(user_ip)
+ city = get_city(user_ip) or get_city_cf(request)
- # Update Average Redirection Time
- alpha = 0.1 # Smoothing factor, adjust as needed
- updates["$set"]["average_redirection_time"] = round(
- (1 - alpha) * curr_avg + alpha * redirection_time, 2
- )
+ # Calculate redirect time in milliseconds
+ redirect_ms = int((time.perf_counter() - start_time) * 1000)
- if is_emoji:
- update_emoji_url(short_code, updates)
- else:
- update_url(short_code, updates)
+ # Check if it's a bot
+ is_bot = crawler_detect.isCrawler(user_agent) or any(
+ re.search(bot, user_agent, re.IGNORECASE) for bot in BOT_USER_AGENTS
+ )
+
+ bot_name = None
+ if is_bot:
+ # Try to identify specific bot
+ if crawler_detect.getMatches():
+ bot_name = crawler_detect.getMatches()
+ else:
+ for bot in BOT_USER_AGENTS:
+ if re.search(bot, user_agent, re.IGNORECASE):
+ bot_name = bot
+ break
+
+ if url_data.get("block_bots", False) and is_bot:
+ # Dont log the click, but do redirect for SEO and meta tags
+ log.info(
+ "bot_blocked",
+ short_code=short_code,
+ bot_name=bot_name if bot_name else "generic",
+ schema="v2",
+ )
+ return
+
+ # Prepare click data for time-series collection following agreed schema
+ curr_time = datetime.now(timezone.utc)
+ click_data = {
+ "clicked_at": curr_time, # timestamp field for time-series
+ "meta": { # meta field for time-series
+ "url_id": url_data["_id"],
+ "short_code": short_code,
+ "owner_id": url_data.get("owner_id"),
+ },
+ "ip_address": user_ip,
+ "country": country or "Unknown",
+ "city": city or "Unknown",
+ "browser": browser,
+ "os": os_name,
+ "device": None, # TODO: find and move to a reliable device detection
+ "redirect_ms": redirect_ms,
+ "referrer": sanitized_referrer_domain, # nullable
+ "bot_name": bot_name, # nullable
+ }
+
+ # Insert click data into time-series collection
+ success = insert_click_data(click_data)
+ if not success:
+ print(f"Failed to insert click data for {short_code}")
+
+ # Update URLsV2 document atomically with new fields
+ update_result = update_url_v2_clicks(url_data["_id"], last_click_time=curr_time)
+ if not update_result.acknowledged:
+ raise InternalRedirectorError("Failed to update click analytics")
+ # Check if URL should be expired due to max_clicks
+ if url_data.get("max_clicks"):
+ expire_result = expire_url_if_max_clicks_reached(
+ url_data["_id"], url_data["max_clicks"]
+ )
+ if expire_result.modified_count > 0:
+ log.info(
+ "url_expired",
+ url_id=str(url_data["_id"]),
+ short_code=short_code,
+ reason="max_clicks_reached",
+ max_clicks=url_data["max_clicks"],
+ )
+ # invalidate the cache
+ cq.invalidate_url_cache(short_code)
+
+ return
+ except RedirectorError:
+ raise
+ except Exception as e:
+ log.error(
+ "click_processing_failed",
+ short_code=short_code,
+ schema="v2",
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ raise InternalRedirectorError() from e
+
+
+def handle_legacy_click(url_data, short_code, is_emoji, user_ip, start_time):
+ """Handle click tracking for legacy v1 schema URLs and emojis"""
+ try:
+ # Get user agent info
+ user_agent = request.headers.get("User-Agent", "")
+ if not user_agent:
+ raise BadRequestError("Invalid User-Agent")
+
+ ua = parse(user_agent)
+ if not ua or not ua.user_agent or not ua.os:
+ raise BadRequestError("Invalid User-Agent")
+
+ os_name = ua.os.family
+ browser = ua.user_agent.family
+ referrer = request.headers.get("Referer")
+ country = get_country(user_ip)
+
+ if country:
+ country = country.replace(".", " ")
+
+ # Build update document for legacy schema
+ updates = {"$inc": {}, "$set": {}, "$addToSet": {}}
+
+ # Handle referrer tracking
+ if referrer:
+ referrer_raw = tld_no_cache_extract(referrer)
+ referrer_domain = (
+ f"{referrer_raw.domain}.{referrer_raw.suffix}"
+ if referrer_raw.suffix
+ else referrer_raw.domain
+ )
+ sanitized_referrer = re.sub(r"[.$\x00-\x1F\x7F-\x9F]", "_", referrer_domain)
+ updates["$inc"][f"referrer.{sanitized_referrer}.counts"] = 1
+ updates["$addToSet"][f"referrer.{sanitized_referrer}.ips"] = user_ip
+
+ # Track analytics
+ updates["$inc"][f"country.{country}.counts"] = 1
+ updates["$addToSet"][f"country.{country}.ips"] = user_ip
+ updates["$inc"][f"browser.{browser}.counts"] = 1
+ updates["$addToSet"][f"browser.{browser}.ips"] = user_ip
+ updates["$inc"][f"os_name.{os_name}.counts"] = 1
+ updates["$addToSet"][f"os_name.{os_name}.ips"] = user_ip
+
+ # Bot detection and blocking
+ for bot in BOT_USER_AGENTS:
+ if re.search(bot, user_agent, re.IGNORECASE):
+ if url_data.get("block_bots", False):
+ log.info(
+ "bot_blocked", short_code=short_code, bot_name=bot, schema="v1"
+ )
+ raise ForbiddenError("Access Denied, Bots not allowed")
+ sanitized_bot = re.sub(r"[.$\x00-\x1F\x7F-\x9F]", "_", bot)
+ updates["$inc"][f"bots.{sanitized_bot}"] = 1
+ break
+ else:
+ if crawler_detect.isCrawler(user_agent):
+ if url_data.get("block_bots", False):
+ log.info(
+ "bot_blocked",
+ short_code=short_code,
+ bot_name=crawler_detect.getMatches(),
+ schema="v1",
+ )
+ raise ForbiddenError("Access Denied, Bots not allowed")
+ updates["$inc"][f"bots.{crawler_detect.getMatches()}"] = 1
+
+ # Daily counters
+ today = str(datetime.now()).split()[0]
+ updates["$inc"][f"counter.{today}"] = 1
+
+ # Check for unique click
+ is_unique_click = user_ip not in url_data.get("ips", [])
+ if is_unique_click:
+ updates["$inc"][f"unique_counter.{today}"] = 1
+
+ updates["$addToSet"]["ips"] = user_ip
+ updates["$inc"]["total-clicks"] = 1
+
+ # Last click info
+ current_time_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
+ updates["$set"]["last-click"] = current_time_str
+ updates["$set"]["last-click-browser"] = browser
+ updates["$set"]["last-click-os"] = os_name
+ updates["$set"]["last-click-country"] = country
+
+ # Calculate and update average redirection time
+ end_time = time.perf_counter()
+ redirection_time = (end_time - start_time) * 1000
+ curr_avg = url_data.get("average_redirection_time", 0)
+ alpha = 0.1
+ updates["$set"]["average_redirection_time"] = round(
+ (1 - alpha) * curr_avg + alpha * redirection_time, 2
+ )
- return redirect(url)
+ # Update the database
+ if is_emoji:
+ update_emoji_url(short_code, updates)
+ else:
+ update_url(short_code, updates)
+
+ return
+ except RedirectorError:
+ raise
+ except Exception as e:
+ log.error(
+ "click_processing_failed",
+ short_code=short_code,
+ schema="v1",
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ raise InternalRedirectorError() from e
@url_redirector.route("//password", methods=["POST"])
@limiter.exempt
def check_password(short_code):
- projection = {
- "_id": 1,
- "password": 1,
- }
-
short_code = unquote(short_code)
- if validate_emoji_alias(short_code):
- url_data = load_emoji_url(short_code, projection)
- else:
- url_data = load_url(short_code, projection)
+ # TODO: Fetch from cache
+ url_data, schema_type = get_url_by_length_and_type(short_code)
if url_data:
- # check if the URL is password protected
- if "password" in url_data:
+ # Check if the URL is password protected
+ password_field = "password"
+ if password_field in url_data and url_data[password_field]:
password = request.form.get("password")
- if password == url_data["password"]:
+
+ # Use different password verification logic based on schema type
+ password_valid = False
+ if schema_type == "v2":
+ # For v2 URLs, use verify_password for hashed passwords
+ password_valid = verify_password(
+ password or "", url_data[password_field]
+ )
+ else:
+ # For v1 URLs, use direct string comparison
+ password_valid = password == url_data[password_field]
+
+ if password_valid:
return redirect(f"{request.host_url}{short_code}?password={password}")
else:
- # show error message for incorrect password
+ # Show error message for incorrect password
+ log.warning(
+ "password_incorrect", short_code=short_code, schema=schema_type
+ )
return render_template(
"password.html",
short_code=short_code,
error="Incorrect password",
host_url=request.host_url,
)
- # show error message for invalid short code
+ else:
+ # URL exists but is not password protected
+ return (
+ render_template(
+ "error.html",
+ error_code="400",
+ error_message="Invalid short code or URL not password-protected",
+ host_url=request.host_url,
+ ),
+ 400,
+ )
+
+ # URL not found
return (
render_template(
"error.html",
diff --git a/blueprints/stats.py b/blueprints/stats.py
index cf463514..c442a7fb 100644
--- a/blueprints/stats.py
+++ b/blueprints/stats.py
@@ -23,11 +23,14 @@
)
from utils.pipeline_utils import get_stats_pipeline
from .limiter import limiter
+from utils.logger import get_logger
from datetime import datetime, timezone
from urllib.parse import unquote
import json
+log = get_logger(__name__)
+
stats = Blueprint("stats", __name__)
@@ -55,6 +58,7 @@ def stats_route():
url_data = load_url(short_code, projection={"password": 1})
if not url_data:
+ log.info("legacy_stats_not_found", short_code=short_code)
return render_template(
"stats.html",
error="Invalid Short Code, short code does not exist!",
@@ -65,6 +69,7 @@ def stats_route():
url_data["password"] = url_data.get("password", None)
if not password and url_data["password"] is not None:
+ log.info("legacy_stats_password_required", short_code=short_code)
return render_template(
"stats.html",
password_error=f"{request.host_url}{short_code} is a password protected Url, please enter the password to continue.",
@@ -73,6 +78,7 @@ def stats_route():
)
if url_data["password"] is not None and url_data["password"] != password:
+ log.warning("legacy_stats_password_incorrect", short_code=short_code)
return render_template(
"stats.html",
password_error="Invalid Password! please enter the correct password to continue.",
@@ -101,6 +107,9 @@ def analytics(short_code):
url_data = aggregate_url(pipeline)
if not url_data:
+ log.info(
+ "legacy_analytics_not_found", short_code=short_code, method=request.method
+ )
if request.method == "GET":
return (
render_template(
@@ -116,6 +125,12 @@ def analytics(short_code):
if url_data["password"] is not None:
if password != url_data["password"]:
+ log.warning(
+ "legacy_analytics_password_incorrect",
+ short_code=short_code,
+ method=request.method,
+ password_provided=bool(password),
+ )
if request.method == "POST":
return (
jsonify(
@@ -156,7 +171,11 @@ def analytics(short_code):
if url_data["expiration-time"] is not None:
expiration_time = convert_to_gmt(url_data["expiration-time"])
if not expiration_time:
- print("Expiration time is not timezone aware")
+ log.warning(
+ "expiration_time_not_timezone_aware",
+ short_code=short_code,
+ expiration_time=url_data["expiration-time"],
+ )
elif expiration_time <= datetime.now(timezone.utc):
url_data["expired"] = True
@@ -213,6 +232,12 @@ def export(short_code, format):
pipeline = get_stats_pipeline(short_code)
if format not in ["csv", "json", "xlsx", "xml"]:
+ log.info(
+ "legacy_export_invalid_format",
+ short_code=short_code,
+ format=format,
+ method=request.method,
+ )
if request.method == "GET":
return (
render_template(
@@ -239,6 +264,12 @@ def export(short_code, format):
url_data = aggregate_url(pipeline)
if not url_data:
+ log.info(
+ "legacy_export_not_found",
+ short_code=short_code,
+ format=format,
+ method=request.method,
+ )
if request.method == "GET":
return (
render_template(
@@ -254,6 +285,13 @@ def export(short_code, format):
if url_data["password"] is not None:
if password != url_data["password"]:
+ log.warning(
+ "legacy_export_password_incorrect",
+ short_code=short_code,
+ format=format,
+ method=request.method,
+ password_provided=bool(password),
+ )
if request.method == "POST":
return (
jsonify(
@@ -285,7 +323,12 @@ def export(short_code, format):
if url_data["expiration-time"] is not None:
expiration_time = convert_to_gmt(url_data["expiration-time"])
if not expiration_time:
- print("Expiration time is not timezone aware")
+ log.warning(
+ "expiration_time_not_timezone_aware",
+ short_code=short_code,
+ format=format,
+ expiration_time=url_data["expiration-time"],
+ )
elif expiration_time <= datetime.now(timezone.utc):
url_data["expired"] = True
diff --git a/blueprints/url_shortener.py b/blueprints/url_shortener.py
index c4fe87c0..7b49a0c7 100644
--- a/blueprints/url_shortener.py
+++ b/blueprints/url_shortener.py
@@ -5,7 +5,6 @@
render_template,
redirect,
url_for,
- make_response,
)
from utils.url_utils import (
get_client_ip,
@@ -25,18 +24,23 @@
check_if_emoji_alias_exists,
validate_blocked_url,
urls_collection,
+ urls_v2_collection,
+ clicks_collection,
+ get_url_v2_by_alias,
)
from utils.general import is_positive_integer, humanize_number
+from utils.logger import get_logger
from .limiter import limiter
from cache import dual_cache
-import json
from datetime import datetime
from urllib.parse import unquote
import tldextract
from crawlerdetect import CrawlerDetect
+import time
url_shortener = Blueprint("url_shortener", __name__)
+log = get_logger(__name__)
crawler_detect = CrawlerDetect()
tld_no_cache_extract = tldextract.TLDExtract(cache_dir=None)
@@ -45,15 +49,11 @@
@url_shortener.route("/", methods=["GET"])
@limiter.exempt
def index():
- recent_urls = []
- short_url_cookie = request.cookies.get("shortURL")
- if short_url_cookie:
- recent_urls = json.loads(short_url_cookie)
- return render_template(
- "index.html", host_url=request.host_url, recentURLs=recent_urls
- )
+ return render_template("index.html", host_url=request.host_url)
+# legacy route URL Shortening route for backwards compatibility, uses the old schema
+# TODO: deprecate this route in the future
@url_shortener.route("/", methods=["POST"])
def shorten_url():
url = request.values.get("url")
@@ -106,6 +106,9 @@ def shorten_url():
short_code = alias[:16]
if alias and check_if_slug_exists(alias[:16]):
+ log.warning(
+ "url_creation_failed", reason="alias_exists", alias=alias[:16], schema="v1"
+ )
if request.headers.get("Accept") == "application/json":
return (
jsonify(
@@ -169,6 +172,17 @@ def shorten_url():
insert_url(short_code, data)
+ log.info(
+ "url_created",
+ alias=short_code,
+ long_url=url,
+ owner_id=None,
+ schema="v1",
+ has_password=bool(password),
+ max_clicks=max_clicks if max_clicks else None,
+ block_bots=bool(block_bots),
+ )
+
response_data = {
"short_url": f"{request.host_url}{short_code}",
"domain": request.host,
@@ -180,18 +194,7 @@ def shorten_url():
if request.headers.get("Accept") == "application/json":
return response
else:
- serialized_list = request.cookies.get("shortURL")
- my_list = json.loads(serialized_list) if serialized_list else []
- my_list.insert(0, short_code)
- if len(my_list) > 3:
- del my_list[-1]
- serialized_list = json.dumps(my_list)
- resp = make_response(
- redirect(url_for("url_shortener.result", short_code=short_code))
- )
- resp.set_cookie("shortURL", serialized_list)
-
- return resp
+ return redirect(url_for("url_shortener.result", short_code=short_code))
@url_shortener.route("/emoji", methods=["GET", "POST"])
@@ -211,6 +214,12 @@ def emoji():
return jsonify({"EmojiError": "Invalid emoji"}), 400
if check_if_emoji_alias_exists(emojies):
+ log.warning(
+ "url_creation_failed",
+ reason="emoji_alias_exists",
+ alias=emojies,
+ schema="v1_emoji",
+ )
return jsonify({"EmojiError": "Emoji already exists"}), 400
else:
while True:
@@ -269,6 +278,17 @@ def emoji():
insert_emoji_url(emojies, data)
+ log.info(
+ "url_created",
+ alias=emojies,
+ long_url=url,
+ owner_id=None,
+ schema="v1_emoji",
+ has_password=bool(password),
+ max_clicks=max_clicks if max_clicks else None,
+ block_bots=bool(block_bots),
+ )
+
response_data = {
"short_url": f"{request.host_url}{emojies}",
"domain": request.host,
@@ -287,13 +307,23 @@ def emoji():
@limiter.exempt
def result(short_code):
short_code = unquote(short_code)
+ v2 = False
if validate_emoji_alias(short_code):
url_data = load_emoji_url(short_code)
else:
- url_data = load_url(short_code)
+ # Try new V2 schema first (aliases >=7 by default but custom may be shorter)
+ url_data = get_url_v2_by_alias(short_code)
+ if url_data:
+ v2 = True
+ else:
+ # Fall back to legacy schema
+ url_data = load_url(short_code)
if url_data:
- short_code = url_data["_id"]
+ if v2:
+ short_code = url_data["alias"]
+ else:
+ short_code = url_data["_id"]
short_url = f"{request.host_url}{short_code}"
return render_template(
"result.html",
@@ -313,7 +343,7 @@ def result(short_code):
)
-METRIC_PIPELINE = [
+METRIC_PIPELINE_V1 = [
{
"$group": {
"_id": None,
@@ -328,12 +358,43 @@ def result(short_code):
@limiter.exempt
def metric():
def query():
- result = urls_collection.aggregate(METRIC_PIPELINE).next()
- del result["_id"]
- result["total-clicks-raw"] = result["total-clicks"]
- result["total-shortlinks-raw"] = result["total-shortlinks"]
- result["total-clicks"] = humanize_number(result["total-clicks"])
- result["total-shortlinks"] = humanize_number(result["total-shortlinks"])
+ start_time = time.time()
+
+ # Get counts from v1 urls collection (legacy)
+ v1_cursor = urls_collection.aggregate(METRIC_PIPELINE_V1)
+ v1_result = next(v1_cursor, {})
+ v1_shortlinks = v1_result.get("total-shortlinks", 0)
+ v1_clicks = v1_result.get("total-clicks", 0)
+
+ # Get document count from v2 urls collection
+ v2_shortlinks = urls_v2_collection.count_documents({})
+
+ # Get document count from clicks time-series collection
+ total_clicks_from_ts = clicks_collection.count_documents({})
+
+ # Combine results
+ total_shortlinks = v1_shortlinks + v2_shortlinks
+ total_clicks = v1_clicks + total_clicks_from_ts
+
+ result = {
+ "total-shortlinks-raw": total_shortlinks,
+ "total-clicks-raw": total_clicks,
+ "total-shortlinks": humanize_number(total_shortlinks),
+ "total-clicks": humanize_number(total_clicks),
+ }
+
+ elapsed_time = time.time() - start_time
+ log.info(
+ "metrics_query_completed",
+ total_shortlinks=total_shortlinks,
+ total_clicks=total_clicks,
+ v1_shortlinks=v1_shortlinks,
+ v2_shortlinks=v2_shortlinks,
+ v1_clicks=v1_clicks,
+ ts_clicks=total_clicks_from_ts,
+ elapsed_ms=round(elapsed_time * 1000, 2),
+ )
+
return result
return jsonify(dual_cache.get_or_set("metrics", query))
diff --git a/bot_user_agents.txt b/bot_user_agents.txt
index 830fd953..65606d3b 100644
--- a/bot_user_agents.txt
+++ b/bot_user_agents.txt
@@ -166,4 +166,17 @@ RetroListeCOM
Snipcart
Missinglettr Bot
Readable
-MainWP
\ No newline at end of file
+MainWP
+python
+spider
+bot
+crawler
+http
+fetch
+curl
+wget
+node
+ruby
+Bluesky
+Zapier
+Discordbot
\ No newline at end of file
diff --git a/builders/__init__.py b/builders/__init__.py
new file mode 100644
index 00000000..703cf250
--- /dev/null
+++ b/builders/__init__.py
@@ -0,0 +1,22 @@
+"""
+URL Request Builders - Business Logic Layer
+
+This package contains all the request builders that handle URL operations.
+These builders encapsulate business logic and validation, separate from the HTTP layer.
+"""
+
+from .base import BaseUrlRequestBuilder
+from .create import ShortenRequestBuilder
+from .update import UpdateUrlRequestBuilder
+from .query import UrlListQueryBuilder
+from .stats import StatsQueryBuilder
+from .exports import ExportBuilder
+
+__all__ = [
+ "BaseUrlRequestBuilder",
+ "ShortenRequestBuilder",
+ "UpdateUrlRequestBuilder",
+ "UrlListQueryBuilder",
+ "StatsQueryBuilder",
+ "ExportBuilder",
+]
diff --git a/builders/base.py b/builders/base.py
new file mode 100644
index 00000000..01f8133c
--- /dev/null
+++ b/builders/base.py
@@ -0,0 +1,212 @@
+from flask import request, jsonify, Response, g
+from datetime import datetime, timezone
+from bson import ObjectId
+from typing import Optional
+
+from utils.url_utils import (
+ validate_url,
+ validate_alias,
+ validate_password,
+)
+from utils.mongo_utils import (
+ check_if_v2_alias_exists,
+ check_if_slug_exists,
+ validate_blocked_url,
+)
+from utils.auth_utils import hash_password, resolve_owner_id_from_request
+from utils.logger import get_logger
+
+log = get_logger(__name__)
+
+
+class BaseUrlRequestBuilder:
+ """Base class for URL request operations (create, update)"""
+
+ def __init__(self, payload: dict):
+ self.payload = payload
+ self.error: Optional[tuple[Response, int]] = None
+ self.now = datetime.now(timezone.utc)
+ self.owner_id = resolve_owner_id_from_request(require_verified=True)
+ self.api_key_doc = getattr(request, "api_key", None)
+
+ # Check if verification error was set
+ verification_error = getattr(g, "verification_error", None)
+ if verification_error:
+ self.error = (jsonify(verification_error), 403)
+
+ # Common fields
+ self.long_url: Optional[str] = None
+ self.alias: Optional[str] = None
+ self.password_hash = None
+ self.block_bots: Optional[bool] = None
+ self.max_clicks: Optional[int] = None
+ self.expire_ts: Optional[int] = None
+ self.private_stats: Optional[bool] = None
+
+ def _fail(self, body: dict, status: int) -> "BaseUrlRequestBuilder":
+ self.error = (jsonify(body), status)
+ return self
+
+ def parse_auth_scope(self, *, required_scopes: set[str]) -> "BaseUrlRequestBuilder":
+ if self.api_key_doc is not None:
+ scopes = set(self.api_key_doc.get("scopes", []))
+ if "admin:all" not in scopes and not any(
+ scope in scopes for scope in required_scopes
+ ):
+ scope_list = ", ".join(required_scopes)
+ log.warning(
+ "url_request_access_denied",
+ reason="missing_scope",
+ required_scopes=list(required_scopes),
+ api_key_scopes=list(scopes),
+ )
+ return self._fail(
+ {"error": f"api key lacks required scope: {scope_list}"}, 403
+ )
+ return self
+
+ def validate_long_url(self) -> "BaseUrlRequestBuilder":
+ self.long_url = self.payload.get("long_url") or self.payload.get("url")
+ if not self.long_url:
+ return self._fail({"error": "long_url is required"}, 400)
+ if not validate_url(self.long_url):
+ log.info(
+ "url_validation_failed",
+ reason="invalid_url_format",
+ url_length=len(self.long_url),
+ url_preview=self.long_url[:100], # Truncate for logging
+ )
+ return self._fail(
+ {
+ "error": "Invalid URL. URL must include a valid protocol and follow RFC patterns.",
+ "field": "long_url",
+ },
+ 400,
+ )
+ if not validate_blocked_url(self.long_url):
+ log.warning(
+ "blocked_url_attempt",
+ url=self.long_url[:100], # Truncate for logging
+ owner_id=str(self.owner_id) if self.owner_id else None,
+ )
+ return self._fail({"error": "Blocked URL"}, 403)
+ return self
+
+ def validate_alias(self) -> "BaseUrlRequestBuilder":
+ custom_alias = self.payload.get("alias")
+ if custom_alias:
+ if not validate_alias(custom_alias):
+ log.info(
+ "alias_validation_failed",
+ reason="invalid_format",
+ alias=custom_alias[:50], # Truncate for logging
+ alias_length=len(custom_alias),
+ )
+ return self._fail({"error": "Invalid alias", "field": "alias"}, 400)
+ alias = custom_alias[:16]
+ if check_if_v2_alias_exists(alias) or check_if_slug_exists(alias):
+ log.info(
+ "alias_conflict",
+ alias=alias,
+ owner_id=str(self.owner_id) if self.owner_id else None,
+ )
+ return self._fail(
+ {"error": "Alias already exists", "field": "alias"}, 409
+ )
+ self.alias = alias
+ return self
+
+ def validate_password(self) -> "BaseUrlRequestBuilder":
+ password = self.payload.get("password")
+ if not password:
+ self.password_hash = None
+ return self
+ if not validate_password(password):
+ log.info("password_validation_failed", password_length=len(password))
+ return self._fail(
+ {
+ "error": "Invalid password: must be >=8 chars, contain a letter, a number and one of '@' or '.' without consecutive specials.",
+ "field": "password",
+ },
+ 400,
+ )
+ self.password_hash = hash_password(password)
+ return self
+
+ def parse_block_bots(self) -> "BaseUrlRequestBuilder":
+ self.block_bots = (
+ bool(self.payload.get("block_bots"))
+ if "block_bots" in self.payload
+ else None
+ )
+ return self
+
+ def parse_max_clicks(self) -> "BaseUrlRequestBuilder":
+ max_clicks = self.payload.get("max_clicks")
+ if max_clicks is None:
+ self.max_clicks = None
+ return self
+ try:
+ max_clicks = int(max_clicks)
+ if max_clicks <= 0:
+ raise ValueError()
+ except Exception as e:
+ log.info(
+ "max_clicks_validation_failed",
+ max_clicks_raw=self.payload.get("max_clicks"),
+ error=str(e),
+ )
+ return self._fail({"error": "max_clicks must be a positive integer"}, 400)
+ self.max_clicks = max_clicks
+ return self
+
+ def parse_expire_after(self) -> "BaseUrlRequestBuilder":
+ expire_after = self.payload.get("expire_after")
+ if expire_after is None:
+ self.expire_ts = None
+ return self
+ try:
+ if isinstance(expire_after, (int, float)):
+ self.expire_ts = int(expire_after)
+ else:
+ raw = str(expire_after)
+ if raw.endswith("Z"):
+ raw = raw[:-1] + "+00:00"
+ dt = datetime.fromisoformat(raw)
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ self.expire_ts = int(dt.timestamp())
+ except Exception as e:
+ log.info(
+ "expire_after_validation_failed",
+ expire_after_raw=str(self.payload.get("expire_after"))[:50],
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return self._fail(
+ {"error": "expire_after must be ISO8601 or epoch seconds"}, 400
+ )
+ return self
+
+ def parse_private_stats(self) -> "BaseUrlRequestBuilder":
+ private_stats = self.payload.get("private_stats")
+ if self.owner_id is not None and private_stats is None:
+ self.private_stats = True
+ elif self.owner_id is not None:
+ self.private_stats = bool(private_stats)
+ else:
+ self.private_stats = None
+ return self
+
+ def _ensure_owner_object_id(self) -> Optional[ObjectId]:
+ """Convert owner_id to ObjectId if needed"""
+ if self.owner_id is None:
+ return None
+ try:
+ return (
+ ObjectId(self.owner_id)
+ if not isinstance(self.owner_id, ObjectId)
+ else self.owner_id
+ )
+ except Exception:
+ return None
diff --git a/builders/create.py b/builders/create.py
new file mode 100644
index 00000000..34005af1
--- /dev/null
+++ b/builders/create.py
@@ -0,0 +1,92 @@
+from flask import request, jsonify, Response
+
+from utils.url_utils import (
+ generate_short_code_v2,
+ get_client_ip,
+)
+from utils.mongo_utils import check_if_v2_alias_exists
+from utils.logger import get_logger
+
+from .base import BaseUrlRequestBuilder
+
+log = get_logger(__name__)
+
+
+class ShortenRequestBuilder(BaseUrlRequestBuilder):
+ """Builder for creating new shortened URLs"""
+
+ def validate_or_generate_alias(self) -> "ShortenRequestBuilder":
+ # Try alias path if provided
+ custom_alias = self.payload.get("alias")
+ if custom_alias:
+ return self.validate_alias()
+ # Otherwise generate
+ while True:
+ candidate = generate_short_code_v2(7)
+ if not check_if_v2_alias_exists(candidate):
+ self.alias = candidate
+ break
+ return self
+
+ def build(self, *, collection) -> tuple[Response, int]:
+ if self.error is not None:
+ return self.error
+ # Final safety checks
+ if not self.long_url or not self.alias:
+ return self._fail({"error": "missing required fields"}, 400).error # type: ignore[return-value]
+
+ # Ensure owner_id is stored as ObjectId for v2 docs
+ owner_oid = self._ensure_owner_object_id()
+
+ doc = {
+ "alias": self.alias,
+ "owner_id": owner_oid,
+ "created_at": self.now,
+ "creation_ip": get_client_ip(),
+ "long_url": self.long_url,
+ "password": self.password_hash,
+ "block_bots": self.block_bots if self.block_bots is not None else None,
+ "max_clicks": self.max_clicks,
+ "expire_after": self.expire_ts,
+ "status": "ACTIVE",
+ "private_stats": self.private_stats,
+ "total_clicks": 0,
+ "last_click": None,
+ }
+
+ try:
+ collection.insert_one(doc)
+
+ log.info(
+ "url_created",
+ alias=self.alias,
+ long_url=self.long_url,
+ owner_id=str(self.owner_id) if self.owner_id else None,
+ schema="v2",
+ has_password=bool(self.password_hash),
+ max_clicks=self.max_clicks,
+ block_bots=self.block_bots,
+ has_expiration=bool(self.expire_ts),
+ private_stats=self.private_stats,
+ )
+ except Exception as e:
+ log.error(
+ "url_creation_failed",
+ reason="database_error",
+ alias=self.alias,
+ schema="v2",
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return jsonify({"error": "database error"}), 500
+
+ body = {
+ "alias": self.alias,
+ "short_url": f"{request.host_url}{self.alias}",
+ "long_url": self.long_url,
+ "owner_id": str(self.owner_id) if self.owner_id else None,
+ "created_at": int(self.now.timestamp()),
+ "status": doc["status"],
+ "private_stats": doc["private_stats"],
+ }
+ return jsonify(body), 201
diff --git a/builders/exports.py b/builders/exports.py
new file mode 100644
index 00000000..0facc7ff
--- /dev/null
+++ b/builders/exports.py
@@ -0,0 +1,350 @@
+from flask import Response, jsonify
+from typing import Optional, Any, Dict
+import io
+import csv
+import zipfile
+import json
+from openpyxl import Workbook
+from openpyxl.styles import Font, Alignment
+from dicttoxml import dicttoxml
+from flask import send_file
+import time
+
+from .stats import StatsQueryBuilder
+from utils.logger import get_logger, should_sample
+
+log = get_logger(__name__)
+
+
+class ExportBuilder:
+ """Builder for exporting URL statistics in various formats (CSV, XLSX, JSON, XML)"""
+
+ def __init__(self, owner_id, args: dict[str, Any]):
+ self.owner_id = owner_id
+ self.args = args
+ self.error: Optional[tuple[Response, int]] = None
+
+ # Export parameters
+ self.format: Optional[str] = None
+ self.stats_data: Optional[Dict[str, Any]] = None
+ self.stats_builder: Optional[StatsQueryBuilder] = None
+
+ # Allowed formats
+ self.allowed_formats = {"csv", "xlsx", "json", "xml"}
+
+ def _fail(self, body: dict, status: int) -> "ExportBuilder":
+ """Set error state"""
+ self.error = (jsonify(body), status)
+ return self
+
+ def parse_format(self) -> "ExportBuilder":
+ """Parse and validate the export format parameter"""
+ if self.error:
+ return self
+
+ format_raw = self.args.get("format", "").strip().lower()
+ if not format_raw:
+ return self._fail(
+ {"error": "format parameter is required (csv, xlsx, json, xml)"}, 400
+ )
+
+ if format_raw not in self.allowed_formats:
+ return self._fail(
+ {
+ "error": f"invalid format - must be one of: {', '.join(self.allowed_formats)}"
+ },
+ 400,
+ )
+
+ self.format = format_raw
+ return self
+
+ def parse_stats(self) -> "ExportBuilder":
+ """
+ Parse statistics using StatsQueryBuilder to avoid duplication.
+ This reuses all the filtering, grouping, and validation logic.
+ """
+ if self.error:
+ return self
+
+ # Create and execute StatsQueryBuilder
+ self.stats_builder = (
+ StatsQueryBuilder(self.owner_id, self.args)
+ .parse_auth_scope()
+ .parse_scope_and_target()
+ .parse_time_range()
+ .parse_filters()
+ .parse_group_by()
+ .parse_metrics()
+ .parse_timezone()
+ )
+
+ # Check if stats builder has any errors
+ if self.stats_builder.error:
+ self.error = self.stats_builder.error
+ return self
+
+ # Get the stats data by building the response
+ try:
+ response, status = self.stats_builder.build()
+ if status != 200:
+ self.error = (response, status)
+ return self
+
+ # Extract JSON data from response
+ self.stats_data = response.get_json()
+ except Exception as e:
+ log.error(
+ "export_stats_fetch_failed", error=str(e), error_type=type(e).__name__
+ )
+ return self._fail({"error": "failed to fetch statistics"}, 500)
+
+ return self
+
+ def build_export(self) -> "ExportBuilder":
+ """Prepare the export file (validation only, actual generation in send())"""
+ if self.error:
+ return self
+
+ if not self.stats_data:
+ return self._fail({"error": "no statistics data available"}, 500)
+
+ return self
+
+ def _export_to_csv(self) -> Response:
+ """Export statistics to CSV format (zipped multiple files)"""
+ output = io.BytesIO()
+
+ with zipfile.ZipFile(
+ output, mode="w", compression=zipfile.ZIP_DEFLATED
+ ) as zipf:
+ # Write summary CSV
+ summary_data = self.stats_data.get("summary", {})
+ time_range = self.stats_data.get("time_range", {})
+ scope = self.stats_data.get("scope", "")
+ timezone_val = self.stats_data.get("timezone", "UTC")
+
+ with zipf.open("summary.csv", "w") as file:
+ with io.TextIOWrapper(file, encoding="utf-8", newline="") as text_file:
+ writer = csv.writer(text_file)
+ writer.writerow(["Metric", "Value"])
+ writer.writerow(["Scope", scope])
+ writer.writerow(["Timezone", timezone_val])
+ writer.writerow(["Start Date", time_range.get("start_date", "N/A")])
+ writer.writerow(["End Date", time_range.get("end_date", "N/A")])
+ writer.writerow(
+ ["Total Clicks", summary_data.get("total_clicks", 0)]
+ )
+ writer.writerow(
+ ["Unique Clicks", summary_data.get("unique_clicks", 0)]
+ )
+ writer.writerow(
+ ["First Click", summary_data.get("first_click", "N/A")]
+ )
+ writer.writerow(
+ ["Last Click", summary_data.get("last_click", "N/A")]
+ )
+ writer.writerow(
+ [
+ "Avg Redirection Time (ms)",
+ summary_data.get("avg_redirection_time", 0),
+ ]
+ )
+
+ # Write metrics CSVs
+ metrics_data = self.stats_data.get("metrics", {})
+ for metric_key, metric_values in metrics_data.items():
+ # Parse metric_key like "clicks_by_time" to get dimension and metric
+ parts = metric_key.split("_by_")
+ if len(parts) == 2:
+ metric_name = parts[0]
+ dimension_name = parts[1]
+
+ filename = f"{metric_key}.csv"
+ with zipf.open(filename, "w") as file:
+ with io.TextIOWrapper(
+ file, encoding="utf-8", newline=""
+ ) as text_file:
+ writer = csv.writer(text_file)
+ # Header
+ writer.writerow([dimension_name.title(), metric_name])
+ # Data rows
+ for row in metric_values:
+ dim_value = row.get(dimension_name, "unknown")
+ metric_value = row.get(metric_name, 0)
+ writer.writerow([dim_value, metric_value])
+
+ output.seek(0)
+ return send_file(
+ output,
+ mimetype="application/zip",
+ as_attachment=True,
+ download_name="spoo-me-export.zip",
+ )
+
+ def _export_to_xlsx(self) -> Response:
+ """Export statistics to Excel format (XLSX)"""
+ output = io.BytesIO()
+ wb = Workbook()
+
+ # Bold font style
+ bold_font = Font(bold=True)
+ center_align = Alignment(horizontal="center")
+
+ # Summary sheet
+ ws_summary = wb.active
+ ws_summary.title = "Summary"
+
+ summary_data = self.stats_data.get("summary", {})
+ time_range = self.stats_data.get("time_range", {})
+ scope = self.stats_data.get("scope", "")
+ timezone_val = self.stats_data.get("timezone", "UTC")
+
+ summary_rows = [
+ ["Metric", "Value"],
+ ["Scope", scope],
+ ["Timezone", timezone_val],
+ ["Start Date", time_range.get("start_date", "N/A")],
+ ["End Date", time_range.get("end_date", "N/A")],
+ ["Total Clicks", summary_data.get("total_clicks", 0)],
+ ["Unique Clicks", summary_data.get("unique_clicks", 0)],
+ ["First Click", summary_data.get("first_click", "N/A")],
+ ["Last Click", summary_data.get("last_click", "N/A")],
+ [
+ "Avg Redirection Time (ms)",
+ summary_data.get("avg_redirection_time", 0),
+ ],
+ ]
+
+ for row in summary_rows:
+ ws_summary.append(row)
+
+ # Style summary sheet
+ for cell in ws_summary["A"]:
+ cell.font = bold_font
+ for cell in ws_summary[1]:
+ cell.font = bold_font
+ cell.alignment = center_align
+ ws_summary.column_dimensions["A"].width = 25
+ ws_summary.column_dimensions["B"].width = 30
+
+ # Metrics sheets
+ metrics_data = self.stats_data.get("metrics", {})
+ for metric_key, metric_values in metrics_data.items():
+ # Parse metric_key like "clicks_by_time" to get dimension and metric
+ parts = metric_key.split("_by_")
+ if len(parts) == 2:
+ metric_name = parts[0]
+ dimension_name = parts[1]
+
+ # Create safe sheet name (Excel has 31 char limit)
+ sheet_name = f"{metric_name}_{dimension_name}"[:31]
+ ws = wb.create_sheet(sheet_name)
+
+ # Header
+ ws.append([dimension_name.title(), metric_name])
+ for cell in ws[1]:
+ cell.font = bold_font
+ cell.alignment = center_align
+
+ # Data rows
+ for row in metric_values:
+ dim_value = row.get(dimension_name, "unknown")
+ metric_value = row.get(metric_name, 0)
+ ws.append([dim_value, metric_value])
+
+ # Set column widths
+ ws.column_dimensions["A"].width = 25
+ ws.column_dimensions["B"].width = 15
+
+ wb.save(output)
+ output.seek(0)
+
+ return send_file(
+ output,
+ mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ as_attachment=True,
+ download_name="spoo-me-export.xlsx",
+ )
+
+ def _export_to_json(self) -> Response:
+ """Export statistics to JSON format"""
+ output = io.StringIO()
+ json.dump(self.stats_data, output, indent=2)
+ output.seek(0)
+
+ output_bytes = io.BytesIO(output.getvalue().encode())
+
+ return send_file(
+ output_bytes,
+ mimetype="application/json",
+ as_attachment=True,
+ download_name="spoo-me-export.json",
+ )
+
+ def _export_to_xml(self) -> Response:
+ """Export statistics to XML format"""
+ # Convert dictionary to XML
+ xml = dicttoxml(self.stats_data, custom_root="statistics", attr_type=False)
+
+ # Create BytesIO object and write XML data to it
+ output = io.BytesIO()
+ output.write(xml)
+ output.seek(0)
+
+ return send_file(
+ output,
+ mimetype="application/xml",
+ as_attachment=True,
+ download_name="spoo-me-export.xml",
+ )
+
+ def send(self) -> tuple[Response, int]:
+ """Generate and send the export file"""
+ if self.error:
+ return self.error
+
+ start_time = time.time()
+
+ try:
+ if self.format == "csv":
+ response = self._export_to_csv(), 200
+ elif self.format == "xlsx":
+ response = self._export_to_xlsx(), 200
+ elif self.format == "json":
+ response = self._export_to_json(), 200
+ elif self.format == "xml":
+ response = self._export_to_xml(), 200
+ else:
+ return self._fail({"error": "unsupported format"}, 400)
+
+ # Sample logging (80%)
+ if should_sample("stats_export"):
+ duration_ms = int((time.time() - start_time) * 1000)
+ # Get export size metrics
+ total_clicks = self.stats_data.get("summary", {}).get("total_clicks", 0)
+ metrics_count = len(self.stats_data.get("metrics", {}))
+
+ log.info(
+ "stats_export",
+ format=self.format,
+ scope=self.stats_builder.scope,
+ short_code=self.stats_builder.short_code
+ if self.stats_builder.scope == "anon"
+ else None,
+ total_clicks=total_clicks,
+ metrics_count=metrics_count,
+ duration_ms=duration_ms,
+ large_export=total_clicks > 10000,
+ )
+
+ return response
+ except Exception as e:
+ log.error(
+ "stats_export_failed",
+ format=self.format,
+ scope=self.stats_builder.scope,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return jsonify({"error": "failed to generate export"}), 500
diff --git a/builders/query.py b/builders/query.py
new file mode 100644
index 00000000..ab1285cc
--- /dev/null
+++ b/builders/query.py
@@ -0,0 +1,326 @@
+from flask import request, jsonify, Response
+from datetime import datetime, timezone
+import json
+import re
+from typing import Optional, Any
+import time
+
+from utils.mongo_utils import urls_v2_collection
+from utils.logger import get_logger, should_sample
+
+log = get_logger(__name__)
+
+
+class UrlListQueryBuilder:
+ """Builder for querying and listing URLs with pagination, filtering, and sorting"""
+
+ def __init__(self, owner_id, args: dict[str, Any]):
+ self.owner_id = owner_id
+ self.args = args
+ self.error: Optional[tuple[Response, int]] = None
+ self.page: int = 1
+ self.page_size: int = 20
+ self.sort_field: str = "created_at"
+ self.sort_order: int = -1 # -1 desc, 1 asc
+ self.filters: dict[str, Any] = {}
+ self.query: dict[str, Any] = {"owner_id": owner_id}
+ self.allowed_sort_fields = {"created_at", "last_click", "total_clicks"}
+ self.projection = {
+ "_id": 1,
+ "alias": 1,
+ "long_url": 1,
+ "status": 1,
+ "created_at": 1,
+ "expire_after": 1,
+ "max_clicks": 1,
+ "private_stats": 1,
+ "password": 1,
+ "total_clicks": 1,
+ "last_click": 1,
+ "block_bots": 1,
+ }
+
+ def _parse_datetime(self, value: Any) -> Optional[datetime]:
+ if value is None:
+ return None
+ try:
+ if isinstance(value, (int, float)):
+ return datetime.fromtimestamp(int(value), tz=timezone.utc)
+ dt = datetime.fromisoformat(str(value))
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt.astimezone(timezone.utc)
+ except Exception:
+ return None
+
+ def _parse_bool(self, value: Any) -> Optional[bool]:
+ if value is None:
+ return None
+ if isinstance(value, bool):
+ return value
+ s = str(value).strip().lower()
+ if s in ("true", "1", "yes"): # common truthy
+ return True
+ if s in ("false", "0", "no"): # common falsy
+ return False
+ return None
+
+ def _fail(self, body: dict, status: int) -> "UrlListQueryBuilder":
+ self.error = (jsonify(body), status)
+ return self
+
+ def parse_auth_scope(self) -> "UrlListQueryBuilder":
+ api_key_doc = getattr(request, "api_key", None)
+ if api_key_doc is not None:
+ scopes = set(api_key_doc.get("scopes", []))
+ if (
+ "admin:all" not in scopes
+ and "urls:manage" not in scopes
+ and "urls:read" not in scopes
+ ):
+ log.warning(
+ "url_list_access_denied",
+ reason="missing_scope",
+ required_scopes=["urls:manage", "urls:read"],
+ api_key_scopes=list(scopes),
+ )
+ return self._fail(
+ {"error": "api key lacks required scope: urls:manage"}, 403
+ )
+ return self
+
+ def parse_pagination(self) -> "UrlListQueryBuilder":
+ try:
+ self.page = int(self.args.get("page", 1))
+ self.page_size = int(self.args.get("pageSize", 20))
+ except Exception as e:
+ log.info(
+ "url_list_pagination_invalid",
+ page_raw=self.args.get("page"),
+ pageSize_raw=self.args.get("pageSize"),
+ error=str(e),
+ )
+ return self._fail({"error": "page and pageSize must be integers"}, 400)
+ if self.page < 1:
+ log.info(
+ "url_list_pagination_invalid", page=self.page, reason="page_less_than_1"
+ )
+ return self._fail({"error": "page must be >= 1", "field": "page"}, 400)
+ if self.page_size < 1 or self.page_size > 100:
+ log.info(
+ "url_list_pagination_invalid",
+ pageSize=self.page_size,
+ reason="pageSize_out_of_range",
+ )
+ return self._fail(
+ {"error": "pageSize must be between 1 and 100", "field": "pageSize"},
+ 400,
+ )
+ return self
+
+ def parse_sort(self) -> "UrlListQueryBuilder":
+ sort_by = (self.args.get("sortBy") or "created_at").strip()
+ sort_order_raw = (self.args.get("sortOrder") or "descending").strip().lower()
+ self.sort_order = -1 if sort_order_raw in ("desc", "descending", "-1") else 1
+ self.sort_field = (
+ sort_by if sort_by in self.allowed_sort_fields else "created_at"
+ )
+ return self
+
+ def parse_filters(self) -> "UrlListQueryBuilder":
+ filter_raw = self.args.get("filter") or self.args.get("filterBy")
+ if filter_raw:
+ try:
+ self.filters = json.loads(filter_raw)
+ if not isinstance(self.filters, dict):
+ log.info(
+ "url_list_filter_invalid",
+ reason="not_dict",
+ filter_type=type(self.filters).__name__,
+ )
+ return self._fail(
+ {"error": "filter must be a JSON object", "field": "filter"},
+ 400,
+ )
+ except json.JSONDecodeError as e:
+ log.info(
+ "url_list_filter_invalid",
+ reason="json_decode_error",
+ error=str(e),
+ filter_raw=filter_raw[:100], # Truncate for logging
+ )
+ return self._fail(
+ {"error": "filter must be valid JSON", "field": "filter"}, 400
+ )
+
+ status_val = self.filters.get("status")
+ if status_val:
+ self.query["status"] = status_val
+
+ created_after = (
+ self._parse_datetime(self.filters.get("createdAfter"))
+ if "createdAfter" in self.filters
+ else None
+ )
+ created_before = (
+ self._parse_datetime(self.filters.get("createdBefore"))
+ if "createdBefore" in self.filters
+ else None
+ )
+ if created_after or created_before:
+ created_range: dict[str, Any] = {}
+ if created_after:
+ created_range["$gte"] = created_after
+ if created_before:
+ created_range["$lte"] = created_before
+ self.query["created_at"] = created_range
+
+ password_set = (
+ self._parse_bool(self.filters.get("passwordSet"))
+ if "passwordSet" in self.filters
+ else None
+ )
+ if password_set is True:
+ self.query["password"] = {"$ne": None}
+ elif password_set is False:
+ self.query["password"] = None
+
+ max_clicks_set = (
+ self._parse_bool(self.filters.get("maxClicksSet"))
+ if "maxClicksSet" in self.filters
+ else None
+ )
+ if max_clicks_set is True:
+ self.query["max_clicks"] = {"$ne": None}
+ elif max_clicks_set is False:
+ self.query["max_clicks"] = None
+
+ search_term = (
+ (self.filters.get("search") or "").strip()
+ if isinstance(self.filters.get("search"), str)
+ else ""
+ )
+ if search_term:
+ try:
+ pattern = re.compile(re.escape(search_term), re.IGNORECASE)
+ self.query["$or"] = [{"alias": pattern}, {"long_url": pattern}]
+ except re.error as e:
+ log.warning(
+ "url_list_search_invalid",
+ search_term=search_term[:100], # Truncate for logging
+ error=str(e),
+ )
+ return self._fail(
+ {"error": "invalid search pattern", "field": "filter.search"},
+ 400,
+ )
+
+ # Placeholder: clicks filters to be implemented later
+ return self
+
+ def build(self) -> tuple[Response, int]:
+ if self.error is not None:
+ return self.error
+
+ start_time = time.time()
+
+ skip = (self.page - 1) * self.page_size
+ limit = self.page_size
+
+ try:
+ total = urls_v2_collection.count_documents(self.query)
+ cursor = (
+ urls_v2_collection.find(self.query, self.projection)
+ .sort(self.sort_field, self.sort_order)
+ .skip(skip)
+ .limit(limit)
+ )
+ docs = list(cursor)
+ except Exception as e:
+ log.error(
+ "url_list_query_failed",
+ owner_id=str(self.owner_id),
+ page=self.page,
+ page_size=self.page_size,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return jsonify({"error": "database error"}), 500
+
+ items = []
+ for d in docs:
+ created_at_iso = None
+ created_at_dt = d.get("created_at")
+ if created_at_dt:
+ if created_at_dt.tzinfo is None:
+ created_at_dt = created_at_dt.replace(tzinfo=timezone.utc)
+ created_at_iso = (
+ created_at_dt.astimezone(timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ )
+ expire_after_ts = (
+ int(d["expire_after"])
+ if isinstance(d.get("expire_after"), (int, float))
+ else None
+ )
+ password_present = d.get("password") is not None
+
+ # Handle last_click in the same format as created_at (ISO string)
+ last_click_iso = None
+ if d.get("last_click"):
+ last_click_dt = d["last_click"]
+ # If the datetime is naive (no timezone), assume it's UTC
+ if last_click_dt.tzinfo is None:
+ last_click_dt = last_click_dt.replace(tzinfo=timezone.utc)
+ # Convert to UTC and format as ISO string
+ last_click_utc = last_click_dt.astimezone(timezone.utc)
+ last_click_iso = last_click_utc.isoformat().replace("+00:00", "Z")
+
+ items.append(
+ {
+ "id": str(d["_id"]),
+ "alias": d.get("alias"),
+ "long_url": d.get("long_url"),
+ "status": d.get("status"),
+ "created_at": created_at_iso,
+ "expire_after": expire_after_ts,
+ "max_clicks": d.get("max_clicks"),
+ "private_stats": d.get("private_stats"),
+ "block_bots": d.get("block_bots", False),
+ "password_set": password_present,
+ "total_clicks": d.get("total_clicks"),
+ "last_click": last_click_iso,
+ }
+ )
+
+ has_next = (skip + len(items)) < total
+ body = {
+ "items": items,
+ "page": self.page,
+ "pageSize": self.page_size,
+ "total": total,
+ "hasNext": has_next,
+ "sortBy": self.sort_field,
+ "sortOrder": "descending" if self.sort_order == -1 else "ascending",
+ }
+
+ # Sample logging (20%)
+ if should_sample("url_list_query"):
+ duration_ms = int((time.time() - start_time) * 1000)
+ log.info(
+ "url_list_query",
+ owner_id=str(self.owner_id),
+ page=self.page,
+ page_size=self.page_size,
+ total=total,
+ results=len(items),
+ sort_by=self.sort_field,
+ sort_order="desc" if self.sort_order == -1 else "asc",
+ has_filters=len(self.filters) > 0,
+ filter_count=len(self.filters),
+ duration_ms=duration_ms,
+ slow_query=duration_ms > 3000,
+ )
+
+ return jsonify(body), 200
diff --git a/builders/stats.py b/builders/stats.py
new file mode 100644
index 00000000..35b1dce3
--- /dev/null
+++ b/builders/stats.py
@@ -0,0 +1,572 @@
+from flask import request, jsonify, Response
+from datetime import datetime, timezone, timedelta
+import json
+from typing import Optional, Any, List, Dict
+from zoneinfo import ZoneInfo, available_timezones
+
+from utils.mongo_utils import (
+ clicks_collection,
+ check_url_stats_privacy,
+)
+from utils.aggregation_strategies import AggregationStrategyFactory
+from utils.query_builder import StatsQueryBuilderFactory
+from utils.stats_utils import format_stats_response_with_metadata, validate_date_range
+from utils.logger import get_logger, should_sample
+
+log = get_logger(__name__)
+
+
+class StatsQueryBuilder:
+ """Builder for querying URL statistics with filtering, grouping, and aggregation"""
+
+ def __init__(self, owner_id, args: dict[str, Any]):
+ self.owner_id = owner_id
+ self.args = args
+ self.error: Optional[tuple[Response, int]] = None
+
+ # Query parameters
+ self.scope: str = "all" # "all" | "anon"
+ self.short_code: Optional[str] = None
+ self.start_date: Optional[datetime] = None
+ self.end_date: Optional[datetime] = None
+ self.filters: Dict[str, List[str]] = {}
+ self.group_by: List[str] = []
+ self.metrics: List[str] = ["clicks", "unique_clicks"]
+ self.timezone: str = "UTC" # IANA timezone for output formatting
+
+ # Allowed values
+ self.allowed_scopes = {"all", "anon"}
+ self.allowed_group_by = {
+ "time",
+ "browser",
+ "os",
+ # "device", # DISABLED: Reliable device detection not available yet
+ "country",
+ "city",
+ "referrer",
+ "short_code",
+ }
+ self.allowed_metrics = {"clicks", "unique_clicks"}
+ # Allowed dimensions for filtering statistics
+ # TODO: "device" is disabled until reliable device detection is implemented
+ self.allowed_filters = {
+ "browser",
+ "os",
+ "country",
+ "city",
+ "referrer",
+ "short_code",
+ }
+
+ def _fail(self, body: dict, status: int) -> "StatsQueryBuilder":
+ self.error = (jsonify(body), status)
+ return self
+
+ def _parse_datetime(self, value: Any) -> Optional[datetime]:
+ if value is None:
+ return None
+ try:
+ if isinstance(value, (int, float)):
+ return datetime.fromtimestamp(int(value), tz=timezone.utc)
+ raw = str(value)
+ if raw.endswith("Z"):
+ raw = raw[:-1] + "+00:00"
+ dt = datetime.fromisoformat(raw)
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt.astimezone(timezone.utc)
+ except Exception:
+ return None
+
+ def _parse_comma_separated(self, value: Any) -> List[str]:
+ if value is None:
+ return []
+ if isinstance(value, list):
+ return [str(item).strip() for item in value]
+ return [item.strip() for item in str(value).split(",") if item.strip()]
+
+ def _convert_datetime_to_timezone(
+ self, dt: Optional[datetime]
+ ) -> Optional[datetime]:
+ """Convert UTC datetime to user's timezone for output"""
+ if dt is None:
+ return None
+ try:
+ # Ensure datetime is in UTC
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ # Convert to user's timezone
+ user_tz = ZoneInfo(self.timezone)
+ return dt.astimezone(user_tz)
+ except Exception as e:
+ log.warning(
+ "timezone_conversion_failed",
+ timezone=self.timezone,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return dt # Return original if conversion fails
+
+ def _format_datetime_in_timezone(self, dt: Optional[datetime]) -> Optional[str]:
+ """Format datetime as ISO string in user's timezone"""
+ converted = self._convert_datetime_to_timezone(dt)
+ return converted.isoformat() if converted else None
+
+ def parse_auth_scope(self) -> "StatsQueryBuilder":
+ api_key_doc = getattr(request, "api_key", None)
+ if api_key_doc is not None:
+ scopes = set(api_key_doc.get("scopes", []))
+ if "admin:all" not in scopes and "stats:read" not in scopes:
+ log.warning(
+ "stats_access_denied",
+ reason="missing_scope",
+ required_scope="stats:read",
+ api_key_scopes=list(scopes),
+ )
+ return self._fail(
+ {"error": "api key lacks required scope: stats:read"}, 403
+ )
+ return self
+
+ def parse_scope_and_target(self) -> "StatsQueryBuilder":
+ self.scope = self.args.get("scope", "all").strip().lower()
+ if self.scope not in self.allowed_scopes:
+ return self._fail(
+ {"error": f"scope must be one of: {', '.join(self.allowed_scopes)}"},
+ 400,
+ )
+
+ if self.scope == "anon":
+ self.short_code = self.args.get("short_code", "").strip()
+ if not self.short_code:
+ return self._fail(
+ {"error": "short_code is required when scope=anon"}, 400
+ )
+
+ # Check URL privacy settings
+ privacy_info = check_url_stats_privacy(self.short_code)
+ if not privacy_info["exists"]:
+ return self._fail({"error": "short_code not found"}, 404)
+
+ # If stats are private, only allow access if user owns the URL
+ if privacy_info["private"]:
+ if self.owner_id is None:
+ log.warning(
+ "stats_access_denied",
+ reason="unauthenticated_private_stats",
+ short_code=self.short_code,
+ )
+ return self._fail(
+ {
+ "error": "this URL has private statistics - authentication required"
+ },
+ 401,
+ )
+
+ # Check if authenticated user owns this URL
+ if privacy_info["owner_id"] != str(self.owner_id):
+ log.warning(
+ "stats_access_denied",
+ reason="not_owner",
+ short_code=self.short_code,
+ requesting_user=str(self.owner_id),
+ owner_user=privacy_info["owner_id"],
+ )
+ return self._fail(
+ {"error": "access denied - private statistics"}, 403
+ )
+
+ elif self.scope == "all":
+ if self.owner_id is None:
+ log.warning(
+ "stats_access_denied",
+ reason="unauthenticated_scope_all",
+ scope="all",
+ )
+ return self._fail(
+ {"error": "authentication required for scope=all"}, 401
+ )
+
+ return self
+
+ def parse_time_range(self) -> "StatsQueryBuilder":
+ self.start_date = self._parse_datetime(self.args.get("start_date"))
+ self.end_date = self._parse_datetime(self.args.get("end_date"))
+
+ # Set default values if not provided
+ now = datetime.now(timezone.utc)
+ if self.start_date is None and self.end_date is None:
+ # Default: end_date is now, start_date is 7 days ago
+ self.end_date = now
+ self.start_date = now - timedelta(days=7)
+ elif self.start_date is None and self.end_date is not None:
+ # If only end_date provided, set start_date to 7 days before end_date
+ self.start_date = self.end_date - timedelta(days=7)
+ elif self.start_date is not None and self.end_date is None:
+ # If only start_date provided, set end_date to now
+ self.end_date = now
+
+ # Cap future dates to current time to handle timing differences
+ if self.start_date and self.start_date > now:
+ self.start_date = now
+ if self.end_date and self.end_date > now:
+ self.end_date = now
+
+ # Validate date range
+ validation = validate_date_range(self.start_date, self.end_date)
+ if not validation["is_valid"]:
+ log.info(
+ "stats_date_range_invalid",
+ start_date=self.start_date.isoformat() if self.start_date else None,
+ end_date=self.end_date.isoformat() if self.end_date else None,
+ error=validation["error"],
+ )
+ return self._fail({"error": validation["error"]}, 400)
+
+ return self
+
+ def parse_filters(self) -> "StatsQueryBuilder":
+ # Parse JSON filters
+ filter_raw = self.args.get("filters")
+ if filter_raw:
+ try:
+ filters_json = json.loads(filter_raw)
+ if isinstance(filters_json, dict):
+ for key, value in filters_json.items():
+ if key in self.allowed_filters:
+ self.filters[key] = self._parse_comma_separated(value)
+ except json.JSONDecodeError:
+ return self._fail({"error": "filters must be valid JSON"}, 400)
+
+ # Parse individual filter parameters
+ for filter_name in self.allowed_filters:
+ filter_value = self.args.get(filter_name)
+ if filter_value:
+ # Skip short_code parameter when scope=anon (it's the scope param, not a filter)
+ if filter_name == "short_code" and self.scope == "anon":
+ continue
+ self.filters[filter_name] = self._parse_comma_separated(filter_value)
+
+ # SECURITY: Prevent filter-based scope bypass
+ # In scope=anon, the short_code is already locked by the scope parameter
+ # Allowing short_code filter would let users bypass privacy checks
+ if self.scope == "anon" and "short_code" in self.filters:
+ log.warning(
+ "stats_scope_bypass_attempt",
+ short_code=self.short_code,
+ attempted_filter=self.filters.get("short_code"),
+ user_id=str(self.owner_id) if self.owner_id else None,
+ )
+ return self._fail(
+ {
+ "error": "short_code filter not allowed with scope=anon - short_code is already specified"
+ },
+ 400,
+ )
+
+ return self
+
+ def parse_group_by(self) -> "StatsQueryBuilder":
+ group_by_raw = self.args.get("group_by", "")
+ self.group_by = self._parse_comma_separated(group_by_raw)
+
+ # Validate group_by values
+ invalid_groups = set(self.group_by) - self.allowed_group_by
+ if invalid_groups:
+ return self._fail(
+ {"error": f"invalid group_by values: {', '.join(invalid_groups)}"}, 400
+ )
+
+ # Default to time if no group_by specified
+ if not self.group_by:
+ self.group_by = ["time"]
+
+ return self
+
+ def parse_metrics(self) -> "StatsQueryBuilder":
+ metrics_raw = self.args.get("metrics", "")
+ if metrics_raw:
+ self.metrics = self._parse_comma_separated(metrics_raw)
+
+ # Validate metrics
+ invalid_metrics = set(self.metrics) - self.allowed_metrics
+ if invalid_metrics:
+ return self._fail(
+ {"error": f"invalid metrics: {', '.join(invalid_metrics)}"}, 400
+ )
+
+ return self
+
+ def parse_timezone(self) -> "StatsQueryBuilder":
+ """Parse and validate timezone parameter for output formatting"""
+ timezone_raw = self.args.get("timezone", "UTC").strip()
+
+ # Map of legacy/deprecated timezone names to current IANA names
+ timezone_aliases = {
+ "Asia/Calcutta": "Asia/Kolkata",
+ "Asia/Katmandu": "Asia/Kathmandu",
+ "Asia/Rangoon": "Asia/Yangon",
+ "Asia/Saigon": "Asia/Ho_Chi_Minh",
+ "US/Eastern": "America/New_York",
+ "US/Central": "America/Chicago",
+ "US/Mountain": "America/Denver",
+ "US/Pacific": "America/Los_Angeles",
+ }
+
+ # Check if it's an alias and convert to canonical name
+ if timezone_raw in timezone_aliases:
+ timezone_raw = timezone_aliases[timezone_raw]
+
+ # Validate timezone - fallback to UTC if invalid
+ if timezone_raw not in available_timezones():
+ log.warning(
+ "invalid_timezone_provided", timezone=timezone_raw, fallback="UTC"
+ )
+ self.timezone = "UTC"
+ else:
+ self.timezone = timezone_raw
+
+ return self
+
+ def _build_click_query(self) -> Dict[str, Any]:
+ """Build MongoDB query for clicks collection using builder pattern"""
+ try:
+ # Use the appropriate factory method based on scope
+ if self.scope == "all":
+ builder = StatsQueryBuilderFactory.for_user_stats(
+ str(self.owner_id), self.start_date, self.end_date
+ )
+ elif self.scope == "anon":
+ builder = StatsQueryBuilderFactory.for_anonymous_stats(
+ self.short_code, self.start_date, self.end_date
+ )
+ else:
+ return self._fail({"error": "invalid scope"}, 400)
+
+ # Add dimension filters
+ builder.with_filters(self.filters)
+
+ return builder.build()
+
+ except Exception as e:
+ log.error(
+ "stats_query_build_failed",
+ scope=self.scope,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ self._fail({"error": "failed to build query"}, 500)
+ return None
+
+ def _build_aggregation_pipeline(
+ self, query: Dict[str, Any]
+ ) -> List[Dict[str, Any]]:
+ """Build aggregation pipeline for statistics using strategy pattern"""
+ # For multiple group_by dimensions, we'll need to run separate aggregations
+ # and combine the results in _execute_aggregations
+ return [] # This will be handled by strategy pattern
+
+ def _execute_aggregations(
+ self, query: Dict[str, Any]
+ ) -> Dict[str, List[Dict[str, Any]]]:
+ """Execute aggregations for each group_by dimension using strategies"""
+ results = {}
+
+ for group_dimension in self.group_by:
+ try:
+ # Pass time range information for time aggregation strategy
+ if group_dimension == "time":
+ strategy = AggregationStrategyFactory.get(
+ group_dimension,
+ start_date=self.start_date,
+ end_date=self.end_date,
+ timezone=self.timezone, # Pass timezone for output conversion
+ )
+ else:
+ strategy = AggregationStrategyFactory.get(group_dimension)
+
+ pipeline = strategy.build_pipeline(query)
+ raw_results = list(clicks_collection.aggregate(pipeline))
+ formatted_results = strategy.format_results(raw_results)
+ results[group_dimension] = formatted_results
+ except Exception as e:
+ log.error(
+ "stats_aggregation_failed",
+ dimension=group_dimension,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ results[group_dimension] = []
+
+ return results
+
+ def _format_results(
+ self, aggregation_results: Dict[str, List[Dict[str, Any]]]
+ ) -> Dict[str, Any]:
+ """Format aggregation results into response structure"""
+ response = {
+ "scope": self.scope,
+ "filters": self.filters,
+ "group_by": self.group_by,
+ "timezone": self.timezone, # Include timezone in response
+ "metrics": {},
+ }
+
+ # Add scope-specific metadata
+ if self.scope == "anon":
+ response["short_code"] = self.short_code
+
+ # Add time range (always present now due to defaults) - converted to user timezone
+ response["time_range"] = {
+ "start_date": self._format_datetime_in_timezone(self.start_date),
+ "end_date": self._format_datetime_in_timezone(self.end_date),
+ }
+
+ # Add time bucketing information if time aggregation is used
+ if "time" in self.group_by and "time" in aggregation_results:
+ try:
+ # Create a temporary strategy to get bucket info
+ time_strategy = AggregationStrategyFactory.get(
+ "time",
+ start_date=self.start_date,
+ end_date=self.end_date,
+ timezone=self.timezone,
+ )
+ if hasattr(time_strategy, "get_bucket_info"):
+ response["time_bucket_info"] = time_strategy.get_bucket_info()
+ except Exception as e:
+ log.warning(
+ "time_bucket_info_failed", error=str(e), error_type=type(e).__name__
+ )
+ # Continue without bucket info if there's an error
+
+ # Add aggregation results for each dimension
+ for dimension, results in aggregation_results.items():
+ for metric in self.metrics:
+ # Map API metric names to result keys
+ result_key = "total_clicks" if metric == "clicks" else metric
+ metric_key = f"{metric}_by_{dimension}"
+
+ response["metrics"][metric_key] = []
+ for result in results:
+ # Handle different field names from different strategies
+ if dimension == "time":
+ dimension_value = result.get("date", "unknown")
+ elif dimension == "short_code":
+ dimension_value = result.get(
+ "short_code", result.get("alias", "unknown")
+ )
+ else:
+ dimension_value = result.get(dimension, "unknown")
+
+ response["metrics"][metric_key].append(
+ {
+ dimension: dimension_value,
+ metric: result.get(result_key, 0),
+ }
+ )
+
+ return response
+
+ def _get_summary_stats(self, query: Dict[str, Any]) -> Dict[str, Any]:
+ """Get overall summary statistics"""
+ pipeline = [
+ {"$match": query},
+ {
+ "$group": {
+ "_id": None,
+ "total_clicks": {"$sum": 1},
+ "unique_clicks": {"$addToSet": "$ip_address"},
+ "first_click": {"$min": "$clicked_at"},
+ "last_click": {"$max": "$clicked_at"},
+ "avg_redirection_time": {"$avg": "$redirect_ms"},
+ }
+ },
+ {"$addFields": {"unique_clicks": {"$size": "$unique_clicks"}}},
+ ]
+
+ try:
+ result = list(clicks_collection.aggregate(pipeline))
+ if result:
+ summary = result[0]
+ return {
+ "total_clicks": summary.get("total_clicks", 0),
+ "unique_clicks": summary.get("unique_clicks", 0),
+ "first_click": self._format_datetime_in_timezone(
+ summary.get("first_click")
+ ),
+ "last_click": self._format_datetime_in_timezone(
+ summary.get("last_click")
+ ),
+ "avg_redirection_time": round(
+ summary.get("avg_redirection_time", 0), 2
+ ),
+ }
+ except Exception:
+ pass
+
+ return {
+ "total_clicks": 0,
+ "unique_clicks": 0,
+ "first_click": None,
+ "last_click": None,
+ "avg_redirection_time": 0,
+ }
+
+ def build(self) -> tuple[Response, int]:
+ if self.error is not None:
+ return self.error
+
+ import time
+
+ start_time = time.time()
+
+ try:
+ # Build query
+ query = self._build_click_query()
+ if self.error is not None:
+ return self.error
+ if not query and self.scope == "anon":
+ return self._fail({"error": "invalid short_code"}, 400)
+
+ # Get summary statistics
+ summary = self._get_summary_stats(query)
+
+ # Execute aggregations using strategy pattern
+ aggregation_results = self._execute_aggregations(query)
+
+ # Format response
+ response = self._format_results(aggregation_results)
+ response["summary"] = summary
+
+ # Enhance response with metadata and computed metrics
+ enhanced_response = format_stats_response_with_metadata(response)
+
+ # Sample logging (20%)
+ if should_sample("stats_query"):
+ duration_ms = int((time.time() - start_time) * 1000)
+ log.info(
+ "stats_query",
+ scope=self.scope,
+ short_code=self.short_code if self.scope == "anon" else None,
+ group_by=self.group_by,
+ metrics=self.metrics,
+ start_date=self.start_date.isoformat() if self.start_date else None,
+ end_date=self.end_date.isoformat() if self.end_date else None,
+ filter_count=len(self.filters),
+ total_clicks=summary.get("total_clicks", 0),
+ unique_clicks=summary.get("unique_clicks", 0),
+ duration_ms=duration_ms,
+ slow_query=duration_ms > 5000,
+ )
+
+ return jsonify(enhanced_response), 200
+
+ except Exception as e:
+ log.error(
+ "stats_query_failed",
+ scope=self.scope,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return jsonify({"error": "database error"}), 500
diff --git a/builders/update.py b/builders/update.py
new file mode 100644
index 00000000..fc890079
--- /dev/null
+++ b/builders/update.py
@@ -0,0 +1,199 @@
+from flask import jsonify, Response
+from bson import ObjectId
+from typing import Optional
+
+from utils.mongo_utils import urls_v2_collection
+from utils.logger import get_logger
+from cache import cache_query as cq
+
+from .base import BaseUrlRequestBuilder
+
+log = get_logger(__name__)
+
+
+class UpdateUrlRequestBuilder(BaseUrlRequestBuilder):
+ """Builder for updating existing URLs"""
+
+ def __init__(self, payload: dict, url_id: str):
+ super().__init__(payload)
+ self.url_id = url_id
+ self.existing_doc: Optional[dict] = None
+
+ def load_and_validate_ownership(self) -> "UpdateUrlRequestBuilder":
+ """Load existing URL and validate ownership"""
+ if not self.url_id:
+ return self._fail({"error": "URL ID is required"}, 400)
+
+ try:
+ url_oid = ObjectId(self.url_id)
+ except Exception:
+ return self._fail({"error": "Invalid URL ID format"}, 400)
+
+ # Load the existing document
+ try:
+ self.existing_doc = urls_v2_collection.find_one({"_id": url_oid})
+ except Exception:
+ return self._fail({"error": "Database error"}, 500)
+
+ if not self.existing_doc:
+ return self._fail({"error": "URL not found"}, 404)
+
+ # Validate ownership
+ owner_oid = self._ensure_owner_object_id()
+ if not owner_oid:
+ return self._fail({"error": "Authentication required"}, 401)
+
+ existing_owner = self.existing_doc.get("owner_id")
+ if existing_owner != owner_oid:
+ return self._fail({"error": "Access denied: You don't own this URL"}, 403)
+
+ return self
+
+ def validate_long_url_if_present(self) -> "UpdateUrlRequestBuilder":
+ """Validate long_url only if it's being updated"""
+ if "long_url" not in self.payload and "url" not in self.payload:
+ return self
+
+ # Use parent validation logic
+ return self.validate_long_url()
+
+ def validate_alias_custom(self) -> "UpdateUrlRequestBuilder":
+ """Provides custom validation for alias updates"""
+ if "alias" not in self.payload:
+ return self.validate_alias()
+
+ alias_value = self.payload.get("alias")
+ # Treat same alias as no-op (idempotent update)
+ if self.existing_doc and alias_value == self.existing_doc.get("alias"):
+ return self
+
+ # use parent validation logic for changed values
+ return self.validate_alias()
+
+ def parse_status_change(self) -> "UpdateUrlRequestBuilder":
+ """Handle status changes (ACTIVE/INACTIVE)"""
+ if "status" not in self.payload:
+ return self
+
+ status = self.payload.get("status")
+ if status not in ["ACTIVE", "INACTIVE"]:
+ return self._fail({"error": "Status must be ACTIVE or INACTIVE"}, 400)
+
+ return self
+
+ def build_update(self) -> tuple[Response, int]:
+ """Execute the update operation"""
+ if self.error is not None:
+ return self.error
+
+ # Build update operations from validated fields
+ update_ops = {}
+
+ # Check each field for changes
+ if self.long_url and self.long_url != self.existing_doc.get("long_url"):
+ update_ops["long_url"] = self.long_url
+
+ if self.alias and self.alias != self.existing_doc.get("alias"):
+ update_ops["alias"] = self.alias
+
+ # Handle password (including removal)
+ if "password" in self.payload:
+ password = self.payload.get("password")
+ if not password and self.existing_doc.get("password"):
+ update_ops["password"] = None
+ elif self.password_hash != self.existing_doc.get("password"):
+ update_ops["password"] = self.password_hash
+
+ # Handle max_clicks (including removal)
+ if "max_clicks" in self.payload:
+ max_clicks = self.payload.get("max_clicks")
+ if (max_clicks is None or max_clicks == 0) and self.existing_doc.get(
+ "max_clicks"
+ ):
+ update_ops["max_clicks"] = None
+ elif self.max_clicks != self.existing_doc.get("max_clicks"):
+ update_ops["max_clicks"] = self.max_clicks
+
+ # Handle expire_after (including removal)
+ if "expire_after" in self.payload:
+ expire_after = self.payload.get("expire_after")
+ if expire_after is None and self.existing_doc.get("expire_after"):
+ update_ops["expire_after"] = None
+ elif self.expire_ts != self.existing_doc.get("expire_after"):
+ update_ops["expire_after"] = self.expire_ts
+
+ # Handle block_bots (including removal)
+ if "block_bots" in self.payload:
+ block_bots = self.payload.get("block_bots")
+ if block_bots is None and self.existing_doc.get("block_bots"):
+ update_ops["block_bots"] = None
+ elif self.block_bots != self.existing_doc.get("block_bots"):
+ update_ops["block_bots"] = self.block_bots
+
+ # Handle private_stats (including removal)
+ if "private_stats" in self.payload:
+ private_stats = self.payload.get("private_stats")
+ if private_stats is None and self.existing_doc.get("private_stats"):
+ update_ops["private_stats"] = None
+ elif self.private_stats != self.existing_doc.get("private_stats"):
+ update_ops["private_stats"] = self.private_stats
+
+ # Handle status change
+ if "status" in self.payload:
+ status = self.payload.get("status")
+ if status != self.existing_doc.get("status"):
+ update_ops["status"] = status
+
+ if not update_ops:
+ return jsonify({"message": "No changes detected"}), 200
+
+ # Add updated_at timestamp
+ update_ops["updated_at"] = self.now
+
+ try:
+ url_oid = ObjectId(self.url_id)
+ result = urls_v2_collection.update_one(
+ {"_id": url_oid}, {"$set": update_ops}
+ )
+
+ if result.matched_count == 0:
+ return jsonify({"error": "URL not found"}), 404
+
+ log.info(
+ "url_updated",
+ url_id=self.url_id,
+ alias=self.existing_doc.get("alias"),
+ owner_id=str(self.owner_id) if self.owner_id else None,
+ fields_changed=list(update_ops.keys()),
+ )
+
+ # invalidate the cache for the URL; for consistent cache state
+ cq.invalidate_url_cache(short_code=self.existing_doc.get("alias"))
+
+ except Exception as e:
+ log.error(
+ "url_update_failed",
+ url_id=self.url_id,
+ alias=self.existing_doc.get("alias") if self.existing_doc else None,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return jsonify({"error": "Database error"}), 500
+
+ # Return updated document info
+ updated_doc = {**self.existing_doc, **update_ops}
+
+ body = {
+ "id": self.url_id,
+ "alias": updated_doc.get("alias"),
+ "long_url": updated_doc.get("long_url"),
+ "status": updated_doc.get("status"),
+ "password_set": updated_doc.get("password") is not None,
+ "max_clicks": updated_doc.get("max_clicks"),
+ "expire_after": updated_doc.get("expire_after"),
+ "block_bots": updated_doc.get("block_bots"),
+ "private_stats": updated_doc.get("private_stats"),
+ "updated_at": int(self.now.timestamp()),
+ }
+
+ return jsonify(body), 200
diff --git a/cache/__init__.py b/cache/__init__.py
index 0e6405fb..1079112d 100644
--- a/cache/__init__.py
+++ b/cache/__init__.py
@@ -7,4 +7,4 @@
from .cache_url import UrlCache
cache_query = UrlCache(ttl_seconds=300)
-dual_cache = DualCache(primary_ttl=300, stale_ttl=1800, lock_ttl=30)
+dual_cache = DualCache(primary_ttl=10 * 60, stale_ttl=60 * 60, lock_ttl=60)
diff --git a/cache/base_cache.py b/cache/base_cache.py
index 8d4d0fb3..fb14a050 100644
--- a/cache/base_cache.py
+++ b/cache/base_cache.py
@@ -1,6 +1,9 @@
from typing import Optional
from redis import Redis
from .redis_client import get_redis
+from utils.logger import get_logger
+
+log = get_logger(__name__)
class BaseCache:
@@ -8,7 +11,9 @@ def __init__(self):
try:
self.r: Optional[Redis] = get_redis()
except Exception as e:
- print(f"[BaseCache] Could not initialize Redis: {e}")
+ log.error(
+ "redis_initialization_failed", error=str(e), error_type=type(e).__name__
+ )
self.r = None
def get(self, key: str):
diff --git a/cache/cache_url.py b/cache/cache_url.py
index 4141c6ae..2c2ad921 100644
--- a/cache/cache_url.py
+++ b/cache/cache_url.py
@@ -3,31 +3,132 @@
from dataclasses import dataclass
from .base_cache import BaseCache
from redis.exceptions import RedisError
+from utils.logger import get_logger
+import warnings
+
+log = get_logger(__name__)
@dataclass
class UrlData:
+ warnings.warn(
+ "[UrlCache] UrlData is deprecated, use UrlCacheData instead",
+ DeprecationWarning,
+ stacklevel=2,
+ )
url: str
short_code: str
password: Optional[str]
block_bots: bool
+@dataclass
+class UrlCacheData:
+ """New cache schema for both old and new URL schemas"""
+
+ _id: str # MongoDB ObjectId as string
+ alias: str
+ long_url: str
+ block_bots: bool
+ password_hash: Optional[str]
+ expiration_time: Optional[int] # Unix timestamp
+ max_clicks: Optional[int]
+ url_status: str # ACTIVE, INACTIVE, BLOCKED, EXPIRED
+ schema_version: str # "v1" or "v2"
+ owner_id: Optional[str] # MongoDB ObjectId as string, null for v1 URLs
+
+
class UrlCache(BaseCache):
def __init__(self, ttl_seconds: int = 300):
super().__init__()
self.ttl_seconds = ttl_seconds
+ def set_url_cache_data(self, short_code: str, url_cache_data: UrlCacheData) -> None:
+ """Set URL data using the new cache schema"""
+ if not self.r:
+ return
+ try:
+ key = f"url_cache:{short_code}"
+ self.r.set(key, json.dumps(url_cache_data.__dict__), ex=self.ttl_seconds)
+ except RedisError as e:
+ log.error(
+ "cache_error",
+ operation="set",
+ key=short_code,
+ cache_type="url",
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+
+ def get_url_cache_data(self, short_code: str) -> Optional[UrlCacheData]:
+ """Get URL data using the new cache schema"""
+ if not self.r:
+ return None
+ try:
+ key = f"url_cache:{short_code}"
+ raw = self.r.get(key)
+ if not raw:
+ return None
+ data = json.loads(raw)
+ return UrlCacheData(**data)
+ except (RedisError, json.JSONDecodeError, TypeError) as e:
+ log.error(
+ "cache_error",
+ operation="get",
+ key=short_code,
+ cache_type="url",
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return None
+
+ def invalidate_url_cache(self, short_code: str) -> None:
+ """Invalidate URL cache data"""
+ if not self.r:
+ return
+ try:
+ key = f"url_cache:{short_code}"
+ self.r.delete(key)
+ log.info(
+ "cache_invalidated", short_code=short_code, reason="manual_invalidation"
+ )
+ except RedisError as e:
+ log.error(
+ "cache_error",
+ operation="delete",
+ key=short_code,
+ cache_type="url",
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+
def set_url_data(self, short_code: str, url_data: UrlData) -> None:
+ warnings.warn(
+ "[UrlCache] set_url_data is deprecated, use set_url_cache_data instead",
+ DeprecationWarning,
+ stacklevel=2,
+ )
if not self.r:
return
try:
key = f"meta:{short_code}"
self.r.set(key, json.dumps(url_data.__dict__), ex=self.ttl_seconds)
except RedisError as e:
- print(f"[UrlCache] Redis SET error: {e}")
+ log.error(
+ "cache_error",
+ operation="set_deprecated",
+ key=short_code,
+ cache_type="url",
+ error=str(e),
+ error_type=type(e).__name__,
+ )
def get_url_data(self, short_code: str) -> Optional[UrlData]:
+ warnings.warn(
+ "[UrlCache] get_url_data is deprecated, use get_url_cache_data instead",
+ DeprecationWarning,
+ stacklevel=2,
+ )
if not self.r:
return None
try:
@@ -38,5 +139,12 @@ def get_url_data(self, short_code: str) -> Optional[UrlData]:
data = json.loads(raw)
return UrlData(**data)
except (RedisError, json.JSONDecodeError, TypeError) as e:
- print(f"[UrlCache] Redis GET error: {e}")
+ log.error(
+ "cache_error",
+ operation="get_deprecated",
+ key=short_code,
+ cache_type="url",
+ error=str(e),
+ error_type=type(e).__name__,
+ )
return None
diff --git a/cache/dual_cache.py b/cache/dual_cache.py
index 1178d025..bfbf5b63 100644
--- a/cache/dual_cache.py
+++ b/cache/dual_cache.py
@@ -3,6 +3,9 @@
import time
from typing import Callable, Any, Optional
from .base_cache import BaseCache
+from utils.logger import get_logger
+
+log = get_logger(__name__)
class DualCache(BaseCache):
@@ -72,4 +75,9 @@ def _refresh(self, base_key, query_fn, serializer_fn=None):
self.set(f"{base_key}:live", serialized, self.primary_ttl)
self.set(f"{base_key}:stale", serialized, self.stale_ttl)
except Exception as e:
- print(f"[SmartCache] Refresh error for {base_key}: {e}")
+ log.error(
+ "cache_refresh_failed",
+ base_key=base_key,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
diff --git a/cache/redis_client.py b/cache/redis_client.py
index 1e612538..868ef23c 100644
--- a/cache/redis_client.py
+++ b/cache/redis_client.py
@@ -1,6 +1,9 @@
import os
import redis
from redis.exceptions import RedisError
+from utils.logger import get_logger
+
+log = get_logger(__name__)
_redis_instance = None # singleton
@@ -10,14 +13,17 @@ def get_redis() -> redis.Redis:
if _redis_instance is None:
redis_uri = os.environ.get("REDIS_URI", None)
if not redis_uri:
+ log.error("redis_uri_not_provided")
raise RuntimeError("[RedisClient] No REDIS_URI provided.")
try:
_redis_instance = redis.Redis.from_url(redis_uri)
_redis_instance.ping()
- print("[RedisClient] Connected to Redis.")
+ log.info("redis_connected")
except RedisError as e:
- print(f"[RedisClient] Redis connection failed: {e}")
+ log.error(
+ "redis_connection_failed", error=str(e), error_type=type(e).__name__
+ )
raise e
return _redis_instance
diff --git a/docker-compose.yml b/docker-compose.yml
index a4c51ed6..1a0e89e4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,15 +1,43 @@
-version: "3"
services:
- flask:
+ db:
+ image: "mongo:latest"
+ container_name: spoo_mongo
+ ports:
+ - "27017:27017"
+ environment:
+ MONGO_INITDB_ROOT_USERNAME: mongouser
+ MONGO_INITDB_ROOT_PASSWORD: mongouser
+ volumes:
+ # Persist MongoDB data and configuration across container restarts
+ - mongo-data:/data/db
+ - mongo-config:/data/configdb
+
+ redis:
+ image: "redis:latest" # reccommended by offical docs
+ container_name: spoo_redis
+ restart: always
+ ports:
+ - "6379:6379"
+
+ app:
build: .
+ container_name: spoo_app
+ command: /app/.venv/bin/uv run main.py
+ volumes:
+ # This mount enables hot reloading for your application code
+ - .:/app
+ # This mount masks the host's .venv from overwriting the container's .venv
+ - /app/.venv
ports:
- "8000:8000"
depends_on:
- - mongo
- env_file:
- - .env
+ - db
+ - redis
+ env_file: .env
+ environment:
+ - MONGODB_URI=mongodb://mongouser:mongouser@db:27017
+ - REDIS_URI=redis://redis:6379/0
- mongo:
- image: "mongo:latest"
- ports:
- - "27017:27017"
+volumes:
+ mongo-data:
+ mongo-config:
diff --git a/dockerfile b/dockerfile
index 8224da1b..b7542fb3 100644
--- a/dockerfile
+++ b/dockerfile
@@ -1,10 +1,13 @@
-FROM python:3.11
+FROM python:3.12-slim
-WORKDIR /app
+# Install uv.
+COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
-COPY ./requirements.txt /app/
-RUN pip install -r requirements.txt
+# Copy the application into the container.
+COPY . /app/
-COPY . .
+# Install the application dependencies.
+WORKDIR /app
+RUN uv sync --frozen --no-cache
-CMD ["python", "main.py"]
\ No newline at end of file
+CMD ["/app/.venv/bin/uv", "run", "main.py", "--watch", "--host", "0.0.0.0", "--port", "8000"]
\ No newline at end of file
diff --git a/main.py b/main.py
index 537c81af..45de80f8 100644
--- a/main.py
+++ b/main.py
@@ -1,4 +1,5 @@
import atexit
+import os
from flask import (
Flask,
@@ -8,6 +9,7 @@
request,
)
from flask_cors import CORS
+import sentry_sdk
from blueprints.api import api
from blueprints.contact import contact
@@ -17,12 +19,54 @@
from blueprints.stats import stats
from blueprints.url_shortener import url_shortener
from blueprints.redirector import url_redirector
-from utils.mongo_utils import client
+from blueprints.auth import auth
+from blueprints.oauth import oauth_bp, init_oauth_for_app
+from blueprints.dashboard import dashboard_bp
+from api.v1 import api_v1
+from utils.mongo_utils import client, ensure_indexes
+from utils.log_context import setup_logging_middleware
+from utils.logger import get_logger, hash_ip
+
+from utils.url_utils import get_client_ip
+from utils.auth_utils import resolve_owner_id_from_request
app = Flask(__name__)
-CORS(app)
+log = get_logger(__name__)
+
+flask_secret = os.getenv("FLASK_SECRET_KEY")
+if flask_secret:
+ app.secret_key = flask_secret
+
+# Enable credentials so refresh cookies can be sent cross-origin from frontend
+CORS(app, supports_credentials=True)
limiter.init_app(app)
+# Initialize OAuth
+init_oauth_for_app(app)
+
+# Setup logging middleware (after OAuth, before routes)
+setup_logging_middleware(app)
+
+if os.getenv("SENTRY_DSN"):
+ sentry_sdk.init(
+ dsn=os.getenv("SENTRY_DSN"),
+ send_default_pii=os.getenv("SENTRY_SEND_PII", "false").lower() == "true",
+ traces_sample_rate=float(os.getenv("SENTRY_TRACES_SAMPLE_RATE", "0.1")),
+ enable_logs=True,
+ profile_session_sample_rate=float(
+ os.getenv("SENTRY_PROFILE_SAMPLE_RATE", "0.05")
+ ),
+ profile_lifecycle="trace",
+ )
+ log.info(
+ "sentry_initialized",
+ pii=os.getenv("SENTRY_SEND_PII", "false").lower() == "true",
+ traces_sample_rate=float(os.getenv("SENTRY_TRACES_SAMPLE_RATE", "0.1")),
+ profile_sample_rate=float(os.getenv("SENTRY_PROFILE_SAMPLE_RATE", "0.05")),
+ )
+
+ensure_indexes()
+
app.register_blueprint(url_shortener)
app.register_blueprint(url_redirector)
app.register_blueprint(docs)
@@ -30,6 +74,10 @@
app.register_blueprint(contact)
app.register_blueprint(api)
app.register_blueprint(stats)
+app.register_blueprint(auth)
+app.register_blueprint(oauth_bp, url_prefix="/oauth")
+app.register_blueprint(dashboard_bp, url_prefix="/dashboard")
+app.register_blueprint(api_v1)
@app.errorhandler(404)
@@ -47,6 +95,17 @@ def page_not_found(error):
@app.errorhandler(429)
def ratelimit_handler(e):
+ # Log rate limit hit
+ owner_id = resolve_owner_id_from_request()
+ log.warning(
+ "rate_limit_hit",
+ path=request.path,
+ method=request.method,
+ limit=e.description,
+ ip_hash=hash_ip(get_client_ip()),
+ user_id=str(owner_id) if owner_id else None,
+ )
+
if request.path == "/contact":
return render_template(
"contact.html",
@@ -66,10 +125,17 @@ def ratelimit_handler(e):
def cleanup():
try:
client.close()
- print("MongoDB connection closed successfully")
+ log.info("mongodb_connection_closed")
except Exception as e:
- print(f"Error closing MongoDB connection: {e}")
+ log.error(
+ "mongodb_connection_close_failed", error=str(e), error_type=type(e).__name__
+ )
if __name__ == "__main__":
- app.run(port=8000, use_reloader=True)
+ app.run(
+ host="0.0.0.0",
+ port=8000,
+ use_reloader=os.getenv("ENV") != "production",
+ debug=os.getenv("ENV") != "production",
+ )
diff --git a/misc/GeoLite2-City.mmdb b/misc/GeoLite2-City.mmdb
new file mode 100644
index 00000000..bed57957
Binary files /dev/null and b/misc/GeoLite2-City.mmdb differ
diff --git a/misc/humans.txt b/misc/humans.txt
index 475db87f..8e097a35 100644
--- a/misc/humans.txt
+++ b/misc/humans.txt
@@ -15,13 +15,15 @@
Desktop App Developer: ivirius
Github: @IviriusMain
+ Rust SDK Developer: redninja
+ Github: @rdni
+
/* THANKS */
Flask
Python
/* SITE */
- Last updated: 2024-03-11
+ Last updated: 2025-11-06
Language: English
Standards: HTML5, CSS3
- Components: Bootstrap
IDE: Visual Studio Code
\ No newline at end of file
diff --git a/misc/robots.txt b/misc/robots.txt
index ae8586c7..c5ca9b39 100644
--- a/misc/robots.txt
+++ b/misc/robots.txt
@@ -1,11 +1,3 @@
User-agent: *
-Disallow: /
-
-Allow: /$
-Allow: /stats
-Allow: /api
-Allow: /report
-Allow: /contact
-Allow: /docs/
-
+Allow: /
Sitemap: https://spoo.me/sitemap.xml
diff --git a/pyproject.toml b/pyproject.toml
index e218c6fb..6d828bf0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,6 +5,8 @@ description = "Open-Source URL Shortener Written in Flask"
readme = "README.md"
requires-python = ">=3.9"
dependencies = [
+ "argon2-cffi>=25.1.0",
+ "authlib>=1.6.5",
"crawlerdetect>=0.3.0",
"dicttoxml>=1.7.16",
"emoji>=2.14.1",
@@ -16,10 +18,13 @@ dependencies = [
"gunicorn>=23.0.0",
"openpyxl>=3.1.5",
"pycountry>=24.6.1",
+ "pyjwt[crypto]>=2.10.1",
"pymongo>=4.13.0",
"python-dotenv>=1.1.0",
"redis>=6.2.0",
"requests>=2.32.3",
+ "sentry-sdk[flask]>=2.44.0",
+ "structlog>=25.5.0",
"tldextract>=5.3.0",
"ua-parser[regex]>=1.0.1",
"validators>=0.35.0",
@@ -33,5 +38,5 @@ dev = [
"pytest-mock>=3.14.1",
"requests-mock>=1.12.1",
"ruff>=0.11.11",
- "uv>=0.7.8",
+ "uv>=0.9.6",
]
diff --git a/render.yaml b/render.yaml
index d5e16f8b..530192db 100644
--- a/render.yaml
+++ b/render.yaml
@@ -14,4 +14,22 @@ services:
- key: URL_REPORT_WEBHOOK
sync: false
- key: REDIS_URI
+ sync: false
+ - key: FLASK_SECRET_KEY
+ sync: false
+ - key: JWT_PRIVATE_KEY
+ sync: false
+ - key: JWT_PUBLIC_KEY
+ sync: false
+ - key: GOOGLE_OAUTH_CLIENT_ID
+ sync: false
+ - key: GOOGLE_OAUTH_CLIENT_SECRET
+ sync: false
+ - key: GITHUB_OAUTH_CLIENT_ID
+ sync: false
+ - key: GITHUB_OAUTH_CLIENT_SECRET
+ sync: false
+ - key: DISCORD_OAUTH_CLIENT_ID
+ sync: false
+ - key: DISCORD_OAUTH_CLIENT_SECRET
sync: false
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 0360de1a..77d80d44 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,66 +1,74 @@
aiohappyeyeballs==2.6.1
-aiohttp==3.12.4
-aiosignal==1.3.2
-attrs==25.3.0
+aiohttp==3.13.2
+aiosignal==1.4.0
+argon2-cffi==25.1.0
+argon2-cffi-bindings==25.1.0
+attrs==25.4.0
+authlib==1.6.5
blinker==1.9.0
cachelib==0.13.0
-certifi==2025.4.26
-charset-normalizer==3.4.2
-click==8.1.8
-colorama==0.4.6
-crawlerdetect==0.3.0
-deprecated==1.2.18
+certifi==2025.10.5
+cffi==2.0.0
+charset-normalizer==3.4.4
+click==8.3.0
+crawlerdetect==0.3.2
+cryptography==46.0.3
+deprecated==1.3.1
dicttoxml==1.7.16
-dnspython==2.7.0
-emoji==2.14.1
+dnspython==2.8.0
+emoji==2.15.0
et-xmlfile==2.0.0
-filelock==3.18.0
-flask==3.1.1
+filelock==3.20.0
+flask==3.1.2
flask-caching==2.3.1
-flask-cors==6.0.0
-flask-limiter==3.11.0
-frozenlist==1.6.0
+flask-cors==6.0.1
+flask-limiter==4.0.0
+frozenlist==1.8.0
geoip2==5.1.0
gunicorn==23.0.0
-idna==3.10
-iniconfig==2.1.0
+idna==3.11
+iniconfig==2.3.0
itsdangerous==2.2.0
jinja2==3.1.6
-limits==4.2
-markdown-it-py==3.0.0
-markupsafe==3.0.2
-maxminddb==2.7.0
+limits==5.6.0
+markdown-it-py==4.0.0
+markupsafe==3.0.3
+maxminddb==2.8.2
mdurl==0.1.2
mongomock==4.3.0
-multidict==6.4.4
+multidict==6.7.0
openpyxl==3.1.5
ordered-set==4.1.0
-packaging==24.2
+packaging==25.0
pluggy==1.6.0
-propcache==0.3.1
+propcache==0.4.1
pycountry==24.6.1
-pygments==2.19.1
-pymongo==4.13.0
-pytest==8.3.5
+pycparser==2.23
+pygments==2.19.2
+pyjwt==2.10.1
+pymongo==4.15.3
+pytest==9.0.0
pytest-flask==1.3.0
-pytest-mock==3.14.1
-python-dotenv==1.1.0
+pytest-mock==3.15.1
+python-dotenv==1.2.1
pytz==2025.2
-redis==6.2.0
-requests==2.32.4
-requests-file==2.1.0
+redis==7.0.1
+requests==2.32.5
+requests-file==3.0.1
requests-mock==1.12.1
-rich==13.9.4
-ruff==0.11.12
-sentinels==1.0.0
+rich==14.2.0
+ruff==0.14.4
+sentinels==1.1.1
+sentry-sdk==2.44.0
+structlog==25.5.0
tldextract==5.3.0
-typing-extensions==4.13.2
+typing-extensions==4.15.0
ua-parser==1.0.1
ua-parser-builtins==0.18.0.post1
-ua-parser-rs==0.1.2
+ua-parser-rs==0.1.3
urllib3==2.5.0
-uv==0.7.8
+uv==0.9.8
validators==0.35.0
werkzeug==3.1.3
-wrapt==1.17.2
-yarl==1.20.0
+wrapt==2.0.1
+yarl==1.22.0
diff --git a/static/css/anychart-ui.min.css b/static/css/anychart-ui.min.css
index df6363e3..75126021 100644
--- a/static/css/anychart-ui.min.css
+++ b/static/css/anychart-ui.min.css
@@ -5,4 +5,4 @@
* Contact: sales@anychart.com
* Copyright: AnyChart.com 2024. All rights reserved.
*/
-.anychart-ui-support{border-style:hidden}* [class^="anychart"]{outline:none}.anychart-inline-block{position:relative;display:-moz-inline-box;display:inline-block}* html .anychart-inline-block{display:inline}* :first-child+html .anychart-inline-block{display:inline}.anychart-hidden{display:none}.anychart-control-disabled{color:#ccc}.anychart-label-input{-webkit-border-radius:1px;-moz-border-radius:1px;border-radius:1px;border:1px solid #d9d9d9;border-top:1px solid silver;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;font-size:13px;height:16px;padding:5px 4px}.anychart-label-input:focus{border-color:#4d90fe}.anychart-label-input.anychart-label-input-label-disabled{color:#ccc}.anychart-thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:2px;-webkit-transition:border 0.2s ease-in-out;transition:border 0.2s ease-in-out}.anychart-thumbnail>img{margin-right:auto;margin-left:auto;display:block;max-width:100%;height:auto}.anychart-thumbnail:hover,.anychart-thumbnail:focus{border-color:#ccc;box-shadow:0 1px 3px rgb(0 0 0 / .2)}.anychart-thumbnail:active{-webkit-box-shadow:inset 0 1px 1px rgb(0 0 0 / .3);-moz-box-shadow:inset 0 1px 1px rgb(0 0 0 / .3);box-shadow:inset 0 1px 1px rgb(0 0 0 / .3);border-color:#489adc}.anychart-loader{background-color:rgb(255 255 255 / .5);position:absolute;top:0;left:0;width:100%;height:100%;z-index:1000}.anychart-loader .anychart-loader-rotating-cover{width:70px;height:70px;position:absolute;top:50%;margin-top:-35px;left:50%;margin-left:-35px}.anychart-loader .anychart-loader-rotating-plane{display:block;width:100%;height:100%;border-radius:20%;border:5px solid #1c75ba;margin:0 auto;position:relative;-webkit-animation:anychart-loader-rotate-plane 3s infinite;animation:anychart-loader-rotate-plane 3s infinite}.anychart-loader .anychart-loader-chart-row{position:absolute;top:10px;bottom:0;left:10px;right:10px;letter-spacing:-3px;line-height:0;font-size:0;white-space:nowrap}.anychart-loader .anychart-loader-chart-row .anychart-loader-chart-col{display:inline-block;width:25%;height:90%;background:#000;margin:0 12.5% 0 0;vertical-align:bottom}.anychart-loader .anychart-loader-chart-row .anychart-loader-chart-col.anychart-loader-green{background:#26a957;height:50%;-webkit-animation:anychart-loader-blink-plane 1.5s infinite;animation:anychart-loader-blink-plane 1.5s infinite}.anychart-loader .anychart-loader-chart-row .anychart-loader-chart-col.anychart-loader-orange{background:#ff8207;height:70%;-webkit-animation:anychart-loader-blink-plane 1.5s infinite 0.15s;animation:anychart-loader-blink-plane 1.5s infinite 0.25s}.anychart-loader .anychart-loader-chart-row .anychart-loader-chart-col.anychart-loader-red{background:#f0402e;height:90%;-webkit-animation:anychart-loader-blink-plane 1.5s infinite 0.3s;animation:anychart-loader-blink-plane 1.5s infinite 0.5s}@keyframes anychart-loader-rotate-plane{0%{-webkit-transform:perspective(120px) rotateX(0deg) rotateY(0deg);transform:perspective(120px) rotateX(0deg) rotateY(0deg);opacity:1}25%{-webkit-transform:perspective(120px) rotateX(-180.1deg) rotateY(0deg);transform:perspective(120px) rotateX(-180.1deg) rotateY(0deg);opacity:.3}50%{-webkit-transform:perspective(120px) rotateX(-180deg) rotateY(-179.9deg);transform:perspective(120px) rotateX(-180deg) rotateY(-179.9deg);opacity:1}75%{-webkit-transform:perspective(120px) rotateX(0deg) rotateY(-180.1deg);transform:perspective(120px) rotateX(0deg) rotateY(-180.1deg);opacity:.3}100%{-webkit-transform:perspective(120px) rotateX(0deg) rotateY(0deg);transform:perspective(120px) rotateX(0deg) rotateY(0deg);opacity:1}}@keyframes anychart-loader-blink-plane{0%{opacity:1}50%{opacity:.01}100%{opacity:1}}.anychart-custom-button{margin:2px;border:0;padding:0;font-family:Arial,sans-serif;color:#000;background:#ddd url(https://cdn.anychart.com/ACDVF/button-bg.png) repeat-x top left;text-decoration:none;list-style:none;vertical-align:middle;cursor:pointer;outline:none}.anychart-custom-button-outer-box,.anychart-custom-button-inner-box{border-style:solid;border-color:#aaa;vertical-align:top}.anychart-custom-button-outer-box{margin:0;border-width:1px 0;padding:0}.anychart-custom-button-inner-box{margin:0 -1px;border-width:0 1px;padding:3px 4px;white-space:nowrap}* html .anychart-custom-button-inner-box{left:-1px}* html .anychart-custom-button-rtl .anychart-custom-button-outer-box{left:-1px}* html .anychart-custom-button-rtl .anychart-custom-button-inner-box{right:auto}* :first-child+html .anychart-custom-button-inner-box{left:-1px}* :first-child+html .anychart-custom-button-rtl .anychart-custom-button-inner-box{left:1px}::root .anychart-custom-button,::root .anychart-custom-button-outer-box{line-height:0}::root .anychart-custom-button-inner-box{line-height:normal}.anychart-custom-button-disabled{background-image:none!important;opacity:.3;-moz-opacity:.3;filter:alpha(opacity=30);cursor:default}.anychart-custom-button-disabled .anychart-custom-button-outer-box,.anychart-custom-button-disabled .anychart-custom-button-inner-box{color:#333333!important;border-color:#999999!important}* html .anychart-custom-button-disabled{margin:2px 1px!important;padding:0 1px!important}* :first-child+html .anychart-custom-button-disabled{margin:2px 1px!important;padding:0 1px!important}.anychart-custom-button-hover .anychart-custom-button-outer-box,.anychart-custom-button-hover .anychart-custom-button-inner-box{border-color:#9cf #69e #69e #77aaff!important}.anychart-custom-button-active,.anychart-custom-button-checked{background-color:#bbb;background-position:bottom left}.anychart-custom-button-focused .anychart-custom-button-outer-box,.anychart-custom-button-focused .anychart-custom-button-inner-box{border-color:orange}.anychart-custom-button-collapse-right,.anychart-custom-button-collapse-right .anychart-custom-button-outer-box,.anychart-custom-button-collapse-right .anychart-custom-button-inner-box{margin-right:0}.anychart-custom-button-collapse-left,.anychart-custom-button-collapse-left .anychart-custom-button-outer-box,.anychart-custom-button-collapse-left .anychart-custom-button-inner-box{margin-left:0}.anychart-custom-button-collapse-left .anychart-custom-button-inner-box{border-left:1px solid #fff}.anychart-custom-button-collapse-left.anychart-custom-button-checked.anychart-custom-button-inner-box{border-left:1px solid #ddd}* html .anychart-custom-button-collapse-left .anychart-custom-button-inner-box{left:0}* :first-child+html .anychart-custom-button-collapse-left.anychart-custom-button-inner-box{left:0}.anychart-button{-webkit-border-radius:2px;-moz-border-radius:2px;border-radius:2px;background-color:#f5f5f5;background-image:-webkit-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:linear-gradient(to bottom,#f5f5f5,#f1f1f1);border:1px solid #dcdcdc;border:1px solid rgb(0 0 0 / .1);color:#333;cursor:pointer;text-align:center;font-family:inherit;font-size:11px;font-weight:700;height:29px;line-height:27px;margin-right:16px;min-width:52px;outline:0;padding:0 8px;white-space:nowrap}.anychart-button:focus{border-color:#4d90fe}.anychart-button:hover{-webkit-box-shadow:0 1px 1px rgb(0 0 0 / .1);-moz-box-shadow:0 1px 1px rgb(0 0 0 / .1);box-shadow:0 1px 1px rgb(0 0 0 / .1)}.anychart-button:active{-webkit-box-shadow:inset 0 1px 1px rgb(0 0 0 / .3);-moz-box-shadow:inset 0 1px 1px rgb(0 0 0 / .3);box-shadow:inset 0 1px 1px rgb(0 0 0 / .3)}.anychart-button i{font-size:11px}.anychart-button.anychart-button-disabled:active{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;cursor:default}.anychart-button-primary{-webkit-border-radius:2px;-moz-border-radius:2px;border-radius:2px;background-color:#4898e6;background-image:-webkit-linear-gradient(to bottom,#4898e6,#4089d0);background-image:-moz-linear-gradient(to bottom,#4898e6,#4089d0);background-image:-ms-linear-gradient(to bottom,#4898e6,#4089d0);background-image:-o-linear-gradient(to bottom,#4898e6,#4089d0);background-image:linear-gradient(to bottom,#4898e6,#4089d0);border:1px solid #1976d2;color:#fff;cursor:pointer;font-size:11px;font-weight:700;height:26px;line-height:24px;margin:0 16px 0 0;min-width:70px;outline:0;padding:0 7px}.anychart-button-primary:hover,.anychart-button-primary:active{background-color:#4898e6;background-image:-webkit-linear-gradient(to bottom,#4898e6,#387ec3);background-image:-moz-linear-gradient(to bottom,#4898e6,#387ec3);background-image:-ms-linear-gradient(to bottom,#4898e6,#387ec3);background-image:-o-linear-gradient(to bottom,#4898e6,#387ec3);background-image:linear-gradient(to bottom,#4898e6,#387ec3);border:1px solid #1976d2;color:#fff}.anychart-button-primary:focus{-webkit-box-shadow:inset 0 0 0 1px #fff;-moz-box-shadow:inset 0 0 0 1px #fff;box-shadow:inset 0 0 0 1px #fff;outline:0}.anychart-button-primary:active{-webkit-box-shadow:inset 0 1px 2px rgb(0 0 0 / .3);-moz-box-shadow:inset 0 1px 2px rgb(0 0 0 / .3);box-shadow:inset 0 1px 2px rgb(0 0 0 / .3)}.anychart-button-primary.anychart-button-disabled{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;background:#4d90fe;background-image:-webkit-linear-gradient(to bottom,#4d90fe,#4089d0);background-image:-moz-linear-gradient(to bottom,#4d90fe,#4089d0);background-image:-ms-linear-gradient(to bottom,#4d90fe,#4089d0);background-image:-o-linear-gradient(to bottom,#4d90fe,#4089d0);background-image:linear-gradient(to bottom,#4d90fe,#4089d0);filter:alpha(opacity=50);opacity:.5;cursor:default}.anychart-button-secondary{-webkit-border-radius:2px;-moz-border-radius:2px;border-radius:2px;background-color:#f5f5f5;background-image:-webkit-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:linear-gradient(to bottom,#f5f5f5,#f1f1f1);border:1px solid #dcdcdc;color:#333;cursor:pointer;font-size:11px;font-weight:700;height:26px;line-height:24px;margin:0 16px 0 0;min-width:70px;outline:0;padding:0 7px}.anychart-button-secondary:hover,.anychart-button-secondary:active{-webkit-box-shadow:0 1px 1px rgb(0 0 0 / .1);-moz-box-shadow:0 1px 1px rgb(0 0 0 / .1);box-shadow:0 1px 1px rgb(0 0 0 / .1);background-color:#f8f8f8;background-image:-webkit-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:linear-gradient(to bottom,#f8f8f8,#f1f1f1);border:1px solid #c6c6c6;color:#111}.anychart-button-secondary:focus{border:1px solid #4d90fe}.anychart-button-secondary:active{-webkit-box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);-moz-box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);box-shadow:inset 0 1px 2px rgb(0 0 0 / .1)}.anychart-button-secondary.anychart-button-disabled{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;background-color:#f5f5f5;background-image:-webkit-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:linear-gradient(to bottom,#f5f5f5,#f1f1f1);border:1px solid #f3f3f3;border:1px solid rgb(0 0 0 / .05);color:#b8b8b8;cursor:default}.anychart-button-standard{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;background-color:#f5f5f5;background-image:-webkit-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:linear-gradient(to bottom,#f5f5f5,#f1f1f1);color:#333;border:1px solid #dcdcdc;border:1px solid rgb(0 0 0 / .1)}.anychart-button-standard:hover{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;background-color:#f8f8f8;background-image:-webkit-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:linear-gradient(to bottom,#f8f8f8,#f1f1f1);border:1px solid #c6c6c6;color:#111}.anychart-button-standard:active{-webkit-box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);-moz-box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);background:#f8f8f8;color:#111}.anychart-button-standard.anychart-button-checked{-webkit-box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);-moz-box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);background-color:#eee;background-image:-webkit-linear-gradient(to bottom,#eeeeee,#e0e0e0);background-image:-moz-linear-gradient(to bottom,#eeeeee,#e0e0e0);background-image:-ms-linear-gradient(to bottom,#eeeeee,#e0e0e0);background-image:-o-linear-gradient(to bottom,#eeeeee,#e0e0e0);background-image:linear-gradient(to bottom,#eeeeee,#e0e0e0);border:1px solid #ccc;color:#333}.anychart-button-standard.anychart-button-disabled{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;background-color:#f5f5f5;background-image:-webkit-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:linear-gradient(to bottom,#f5f5f5,#f1f1f1);border:1px solid #f3f3f3;border:1px solid rgb(0 0 0 / .05);color:#b8b8b8;cursor:default}.anychart-button-toggle{height:28px;line-height:24px;padding:0;min-width:27px;margin:0;vertical-align:middle}.anychart-button.anychart-button-toggle{z-index:auto}.anychart-button-collapse-left,.anychart-button-collapse-right{z-index:1}.anychart-button-collapse-left.anychart-button-checked,.anychart-button-collapse-right.anychart-button-checked{z-index:2}.anychart-button-collapse-left:hover,.anychart-button-collapse-right:hover{z-index:3}.anychart-button-collapse-left.anychart-button-disabled{z-index:0}.anychart-button-collapse-right{margin-right:0;-moz-border-radius-topright:0;-moz-border-radius-bottomright:0;-webkit-border-top-right-radius:0;-webkit-border-bottom-right-radius:0;border-top-right-radius:0;border-bottom-right-radius:0}.anychart-button-collapse-left{margin-left:-1px;-moz-border-radius-bottomleft:0;-moz-border-radius-topleft:0;-webkit-border-bottom-left-radius:0;-webkit-border-top-left-radius:0;border-bottom-left-radius:0;border-top-left-radius:0}.anychart-menu{-webkit-border-radius:0;-moz-border-radius:0;-webkit-box-shadow:0 2px 4px rgb(0 0 0 / .2);-moz-box-shadow:0 2px 4px rgb(0 0 0 / .2);box-shadow:0 2px 4px rgb(0 0 0 / .2);cursor:default;font-size:13px;font-family:Arial,sans-serif;margin:0;outline:none;position:absolute;z-index:1003;line-height:normal;background:rgb(0 0 0 / .5);backdrop-filter:blur(10px) saturate(1.5);border-radius:8px;border:1px solid rgba(255,2550,255,.24);color:white!important;padding:4px}.anychart-menuitem{position:relative;color:#333;cursor:pointer;list-style:none;margin:0;padding:8px 1em 8px 30px;white-space:nowrap}.anychart-menuitem.anychart-menuitem-rtl{padding-left:7em;padding-right:28px}.anychart-menu-nocheckbox .anychart-menuitem,.anychart-menu-noicon .anychart-menuitem{padding-left:12px}.anychart-menu-noaccel .anychart-menuitem{padding-right:20px}.anychart-menuitem-disabled{cursor:default}.anychart-menuitem-disabled .anychart-menuitem-accel,.anychart-menuitem-disabled .anychart-menuitem-content{color:white!important}.anychart-menuitem-disabled .anychart-menuitem-icon{opacity:.3;-moz-opacity:.3;filter:alpha(opacity=30)}.anychart-menuitem-highlight,.anychart-menuitem-hover{background-color:rgb(255 255 255 / .1)!important;border:none!important;border-radius:4px}.anychart-menuitem-checkbox,.anychart-menuitem-icon{background-repeat:no-repeat;height:21px;left:3px;position:absolute;right:auto;top:3px;vertical-align:middle;width:21px}.anychart-menuitem i{position:absolute;left:9px;color:whitesmoke}.anychart-menuitem-link{padding:0}.anychart-menuitem-link a{text-decoration:none;color:inherit;display:inline-block;padding:6px 3em 6px 28px;width:100%;box-sizing:border-box;transition:none}.anychart-menuitem-link i{padding-top:7px;pointer-events:none}.anychart-menuitem-link.anychart-menuitem-highlight a{padding-bottom:5px;padding-top:5px}.anychart-menuitem-link.anychart-menuitem-highlight i{padding-top:6px}.anychart-menuitem-rtl .anychart-menuitem-checkbox,.anychart-menuitem-rtl .anychart-menuitem-icon{left:auto;right:6px}.anychart-menuitem-accel{color:#777;direction:ltr;left:auto;float:right;padding:0 0 0 24px;position:relative;right:0;text-align:right}.anychart-menuitem-rtl .anychart-menuitem-accel{left:0;right:auto;text-align:left}.anychart-menuitem-mnemonic-hint{text-decoration:underline}.anychart-menuitem-mnemonic-separator{color:#999;font-size:12px;padding-left:4px}.anychart-menuseparator{border-top:.5px solid rgb(255 255 255 / .1);margin:6px -4px}.anychart-submenu-arrow{color:#b3b3b3;opacity:.9;filter:alpha(opacity=50);position:absolute;right:-20px;top:3px;border-top:5px solid #fff0;border-bottom:5px solid #fff0;border-left:5px solid #b3b3b3;font-size:0}.anychart-menuitem-content{position:relative;color:#fff}.anychart-menuitem-highlight .anychart-submenu-arrow,.anychart-menuitem-hover .anychart-submenu-arrow{opacity:1}.anychart-menuitem-rtl .anychart-submenu-arrow{text-align:left;left:0;right:auto;padding-left:6px}.anychart-menuitem-disabled .anychart-submenu-arrow{border-left-color:#ccc;opacity:1}.anychart-menu-scrollable{overflow-y:auto}.anychart-menu.anychart-menu-horizontal{padding-left:4px;padding-right:4px}.anychart-menu.anychart-menu-horizontal .anychart-menuitem{display:inline-block;padding:2px 3px}.anychart-menu.anychart-menu-horizontal .anychart-menuitem.anychart-menuitem-highlight,.anychart-menu.anychart-menu-horizontal .anychart-menuitem .anychart-menuitem-hover{padding-top:1px;padding-bottom:1px}.anychart-menuitem.anychart-option i{position:relative;left:1px;height:23px;padding-top:1px;width:23px}.anychart-menuitem.anychart-option i.ac-position-center{top:-1px}.anychart-menu-button{background:#ddd url(https://cdn.anychart.com/ACDVF/button-bg.png) repeat-x top left;border:0;color:#000;cursor:pointer;list-style:none;margin:2px;outline:none;padding:0;text-decoration:none;vertical-align:middle}.anychart-menu-button-outer-box,.anychart-menu-button-inner-box{border-style:solid;border-color:#aaa;vertical-align:top}.anychart-menu-button-outer-box{margin:0;border-width:1px 0;padding:0}.anychart-menu-button-inner-box{margin:0 -1px;border-width:0 1px;padding:3px 4px}* html .anychart-menu-button-inner-box{left:-1px}* html .anychart-menu-button-rtl .anychart-menu-button-outer-box{left:-1px;right:auto}* html .anychart-menu-button-rtl .anychart-menu-button-inner-box{right:auto}* :first-child+html .anychart-menu-button-inner-box{left:-1px}* :first-child+html .anychart-menu-button-rtl .anychart-menu-button-inner-box{left:1px;right:auto}::root .anychart-menu-button,::root .anychart-menu-button-outer-box,::root .anychart-menu-button-inner-box{line-height:0}::root .anychart-menu-button-caption,::root .anychart-menu-button-dropdown{line-height:normal}.anychart-menu-button-disabled{background-image:none!important;opacity:.3;-moz-opacity:.3;filter:alpha(opacity=30);cursor:default}.anychart-menu-button-disabled .anychart-menu-button-outer-box,.anychart-menu-button-disabled .anychart-menu-button-inner-box,.anychart-menu-button-disabled .anychart-menu-button-caption,.anychart-menu-button-disabled .anychart-menu-button-dropdown{color:#333333!important;border-color:#999999!important}* html .anychart-menu-button-disabled{margin:2px 1px!important;padding:0 1px!important}* :first-child+html .anychart-menu-button-disabled{margin:2px 1px!important;padding:0 1px!important}.anychart-menu-button-hover .anychart-menu-button-outer-box,.anychart-menu-button-hover .anychart-menu-button-inner-box{border-color:#9cf #69e #69e #77aaff!important}.anychart-menu-button-active,.anychart-menu-button-open{background-color:#bbb;background-position:bottom left}.anychart-menu-button-focused .anychart-menu-button-outer-box,.anychart-menu-button-focused .anychart-menu-button-inner-box{border-color:orange}.anychart-menu-button-caption{padding:0 4px 0 0;vertical-align:top}.anychart-menu-button-dropdown{height:15px;width:7px;background:url(https://cdn.anychart.com/ACDVF/editortoolbar.png) no-repeat -388px 0;vertical-align:top}.anychart-menu-button-collapse-right,.anychart-menu-button-collapse-right .anychart-menu-button-outer-box,.anychart-menu-button-collapse-right .anychart-menu-button-inner-box{margin-right:0}.anychart-menu-button-collapse-left,.anychart-menu-button-collapse-left .anychart-menu-button-outer-box,.anychart-menu-button-collapse-left .anychart-menu-button-inner-box{margin-left:0}.anychart-menu-button-collapse-left .anychart-menu-button-inner-box{border-left:1px solid #fff}.anychart-menu-button-collapse-left.anychart-menu-button-checked.anychart-menu-button-inner-box{border-left:1px solid #ddd}.anychart-flat-menu-button{-webkit-border-radius:2px;-moz-border-radius:2px;border-radius:2px;background-color:#f5f5f5;background-image:-webkit-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:linear-gradient(to bottom,#f5f5f5,#f1f1f1);-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;border:1px solid #dcdcdc;color:#333;cursor:pointer;font-size:11px;font-weight:700;line-height:27px;list-style:none;margin:0 2px;min-width:46px;outline:none;padding:0 18px 0 6px;text-align:center;text-decoration:none}.anychart-flat-menu-button-disabled{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;background-color:#f5f5f5;background-image:-webkit-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:linear-gradient(to bottom,#f5f5f5,#f1f1f1);border:1px solid #f3f3f3;border:1px solid rgb(0 0 0 / .05);color:#b8b8b8;cursor:default}.anychart-flat-menu-button-disabled .anychart-flat-menu-button-dropdown{border-color:#b8b8b8 #fff0}.anychart-flat-menu-button-hover{background-color:#f8f8f8;background-image:-webkit-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:linear-gradient(to bottom,#f8f8f8,#f1f1f1);-webkit-box-shadow:0 1px 1px rgb(0 0 0 / .1);-moz-box-shadow:0 1px 1px rgb(0 0 0 / .1);box-shadow:0 1px 1px rgb(0 0 0 / .1);border-color:#c6c6c6;color:#111}.anychart-flat-menu-button.anychart-flat-menu-button-open,.anychart-flat-menu-button.anychart-flat-menu-button-active{-webkit-box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);-moz-box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);background-color:#eee;background-image:-webkit-linear-gradient(to bottom,#eeeeee,#e0e0e0);background-image:-moz-linear-gradient(to bottom,#eeeeee,#e0e0e0);background-image:-ms-linear-gradient(to bottom,#eeeeee,#e0e0e0);background-image:-o-linear-gradient(to bottom,#eeeeee,#e0e0e0);background-image:linear-gradient(to bottom,#eeeeee,#e0e0e0);border:1px solid #ccc;color:#333;z-index:2}.anychart-flat-menu-button-focused{border-color:#4d90fe}.anychart-flat-menu-button-caption{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;white-space:nowrap;padding-right:20px;text-overflow:ellipsis;overflow:hidden;width:100%}.anychart-flat-menu-button-dropdown{border-color:#777 #fff0;border-style:solid;border-width:4px 4px 0 4px;height:0;width:0;position:absolute;right:5px;top:12px}.anychart-flat-menu-button-active .anychart-flat-menu-button-dropdown,.anychart-flat-menu-button-open .anychart-flat-menu-button-dropdown,.anychart-flat-menu-button-selected .anychart-flat-menu-button-dropdown,.anychart-flat-menu-button-hover .anychart-flat-menu-button-dropdown{border-color:#595959 #fff0}.anychart-combobox{background:0;background-color:whiteSmoke;background-image:-webkit-linear-gradient(to bottom,whiteSmoke,#f1f1f1);background-image:-moz-linear-gradient(to bottom,whiteSmoke,#f1f1f1);background-image:-ms-linear-gradient(to bottom,whiteSmoke,#f1f1f1);background-image:-o-linear-gradient(to bottom,whiteSmoke,#f1f1f1);background-image:linear-gradient(to bottom,whiteSmoke,#f1f1f1);border:1px solid gainsboro;border:1px solid rgb(0 0 0 / .1);-webkit-border-radius:2px;-moz-border-radius:2px;border-radius:2px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;font:normal small arial,sans-serif;height:24px;color:#333;line-height:24px;list-style:none;font-size:11px;font-weight:700;text-decoration:none;vertical-align:middle;cursor:pointer;margin-left:4px;padding:1px 0;top:auto}.anychart-combobox:hover{-webkit-box-shadow:0 1px 1px rgb(0 0 0 / .1);-moz-box-shadow:0 1px 1px rgb(0 0 0 / .1);box-shadow:0 1px 1px rgb(0 0 0 / .1);background-color:#f8f8f8;background-image:-webkit-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:linear-gradient(to bottom,#f8f8f8,#f1f1f1);border-color:#c6c6c6;color:#222}.anychart-combobox:hover input{border-right-color:#d9d9d9}.anychart-combobox.anychart-combobox-disabled{background-color:whiteSmoke;background-image:-webkit-linear-gradient(to bottom,whiteSmoke,#f1f1f1);background-image:-moz-linear-gradient(to bottom,whiteSmoke,#f1f1f1);background-image:-ms-linear-gradient(to bottom,whiteSmoke,#f1f1f1);background-image:-o-linear-gradient(to bottom,whiteSmoke,#f1f1f1);background-image:linear-gradient(to bottom,whiteSmoke,#f1f1f1);border:1px solid #f2f2f2;opacity:1;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;cursor:default}.anychart-combobox.anychart-combobox-disabled input{color:#b8b8b8;border-right-color:#fff0}.anychart-combobox.anychart-combobox-disabled .anychart-combobox-button{opacity:.4}.anychart-combobox input{-webkit-border-radius:1px;-moz-border-radius:1px;border-radius:1px;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;background:#fff0;border:1px solid #fff0;color:#333;font-family:Arial,sans-serif;font-size:11px;font-weight:700;height:20px;overflow:hidden;padding:0 0 0 3px;position:relative;margin-right:18px}.anychart-combobox input:focus{-webkit-box-shadow:inset 0 1px 2px rgb(0 0 0 / .3);-moz-box-shadow:inset 0 1px 2px rgb(0 0 0 / .3);box-shadow:inset 0 1px 2px rgb(0 0 0 / .3);-webkit-user-select:text;-moz-user-select:text;background:#fff;border:1px solid #4d90fe;height:20px;outline:none}.anychart-combobox-button{display:inline-block;border-color:#777 #fff0;border-style:solid;border-width:4px 4px 0 4px;height:0;width:0;position:absolute;right:5px;top:12px}.anychart-checkbox{cursor:pointer;display:inline-block;margin:2px 2px 2px 0;overflow:hidden;padding:0;position:relative;white-space:nowrap;vertical-align:middle}.anychart-checkbox .anychart-checkbox-element{-webkit-border-radius:1px;-moz-border-radius:1px;border-radius:1px;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;background-color:rgb(255 255 255 / .05);border:1px solid #c6c6c6;border:1px solid rgb(155 155 155 / .57);height:11px;margin:0 4px 1px 1px;outline:0;vertical-align:text-bottom;width:11px}.anychart-checkbox-hover .anychart-checkbox-element{-webkit-box-shadow:inset 0 1px 1px rgb(0 0 0 / .1);-moz-box-shadow:inset 0 1px 1px rgb(0 0 0 / .1);box-shadow:inset 0 1px 1px rgb(0 0 0 / .1);border:1px solid #b2b2b2}.anychart-checkbox-focused .anychart-checkbox-element{border:1px solid #4d90fe}.anychart-checkbox-checked{background-color:#fff;background-color:rgb(255 255 255 / .65)}.anychart-checkbox-checked .anychart-checkbox-checkmark{display:inline-block}.anychart-checkbox-disabled{cursor:default;color:#b8b8b8}.anychart-checkbox-disabled .anychart-checkbox-element{background-color:#fff;border:1px solid #f1f1f1;cursor:default}.anychart-checkbox-checkmark{display:none;opacity:.6;height:15px;outline:0;width:15px;left:0;position:relative;top:-3px}.anychart-palette{cursor:default;outline:none}.anychart-palette-table{empty-cells:show;margin:16px}.anychart-palette-cell{border:1px solid #fff0;cursor:pointer;margin:0;position:relative}.anychart-plot-controls{position:absolute}.anychart-plot-controls .anychart-button{min-width:20px;height:20px;opacity:.7;line-height:0;vertical-align:middle;margin:0;padding:0;display:inline-block}.anychart-plot-controls-hidden{visibility:hidden}.anychart-palette-colorswatch{border:none;font-size:14px;height:16px;position:relative;width:16px;display:block}.anychart-palette-colorswatch:before{display:none;position:relative;top:1px;left:2px}.anychart-palette-cell-hover{border:1px solid #000}.anychart-palette-cell-selected{outline:1px solid #000}.anychart-palette-cell-selected .anychart-palette-colorswatch:before{display:inline-block}.anychart-color-menu-button-indicator{height:14px;margin-left:4px;outline:1px solid #bbb;width:14px}.anychart-color-menu-button .anychart-menu-button-inner-box,.anychart-toolbar-color-menu-button .anychart-toolbar-menu-button-inner-box{padding-top:2px!important;padding-bottom:2px!important}.anychart-toolbar{background-color:#f7f7f7;border:1px solid #d5d5d5;cursor:default;font:normal 12px Verdana,sans-serif;color:#7c868e;padding:2px;position:relative}.anychart-toolbar-button,.anychart-toolbar-menu-button{margin:0 2px;border:0;padding:2px 2px;text-decoration:none;vertical-align:middle;list-style:none;cursor:default;outline:none}.anychart-toolbar-button-inner-box,.anychart-toolbar-menu-button-inner-box{padding:3px 4px}.anychart-toolbar-button-hover,.anychart-toolbar-menu-button-hover{background-color:#eee}.anychart-toolbar-menu-button-active,.anychart-toolbar-menu-button-open,.anychart-toolbar-button-active,.anychart-toolbar-button-checked,.anychart-toolbar-button-selected{background-color:#dddddd!important}.anychart-toolbar-menu-button-dropdown{margin-left:3px;padding-bottom:1px;width:0;height:0;border-left:4px solid #fff0;border-right:4px solid #fff0;border-top:4px solid #7c868e;vertical-align:middle}.anychart-toolbar-separator{margin:0 2px;border-left:1px solid #d6d6d6;border-right:1px solid #f7f7f7;padding:0;width:0;text-decoration:none;list-style:none;outline:none;vertical-align:middle;line-height:normal;font-size:120%;overflow:hidden}.anychart-toolbar-item-icon{padding:0 2px;font-size:13px;color:#2485d0}.anychart-toolbar-item-text{padding:0 2px}.anychart-toolbar-menu{background-color:#f7f7f7;border:1px solid #d5d5d5;cursor:default;font:normal 12px Verdana,sans-serif;color:#7c868e;margin:0;outline:none;padding:6px 0;position:absolute;z-index:1003;line-height:normal}.anychart-toolbar-menu .anychart-menuitem{color:#7c868e}.anychart-toolbar-menu-button-hover .anychart-toolbar-menu-button-dropdown,.anychart-toolbar-menu-button-open .anychart-toolbar-menu-button-dropdown{border-top:4px solid #000}.anychart-toolbar-menu .anychart-menuitem i,.anychart-toolbar-menu .anychart-menuitem-content{display:inline;position:static}.anychart-toolbar-menu .anychart-menuitem{padding:6px 5px;padding-right:8px;border-style:none}.anychart-toolbar-menu .anychart-submenu-arrow{position:absolute;right:0;top:9px;padding:0 2px}.anychart-menuitem-content{margin-right:1rem}.anychart-option[role="menuitemradio"]{padding-left:1.5rem}.anychart-option-selected .anychart-menuitem-checkbox:before{content:"\2713";display:block;width:100%;height:100%;text-align:center}.anychart-range-picker{font-family:Arial,sans-serif;font-size:11px;color:#7c868e}.anychart-range-picker.anychart-range-picker-inside{position:absolute;bottom:27px;left:20px}.anychart-range-picker .anychart-label-input{font-size:11px;margin:0 5px;height:13px;padding:3px 4px;width:90px;font-weight:400}.anychart-range-picker .anychart-input-label{margin-bottom:0;font-weight:400}.anychart-range-selector{font-family:Arial,sans-serif;font-size:11px;color:#7c868e}.anychart-range-selector.anychart-range-selector-inside{position:absolute;bottom:27px;right:25px}.anychart-range-selector .anychart-input-label{margin-right:5px;vertical-align:middle;margin-bottom:0;font-weight:400}.anychart-range-selector .anychart-button{height:21px;line-height:19px;padding:0 5px}.anychart-zoom{position:absolute;left:15px;top:10px}.anychart-zoom .anychart-button{min-width:21px;padding:0;line-height:normal;display:block;margin:5px 0;height:26px;width:26px;margin:5px 0;backdrop-filter:blur(10px) contrast(.8);border-radius:5px;background:rgb(255 255 255 / .125);border:1px solid rgb(255 255 255 / .18);color:#fff}.anychart-zoom .anychart-zoom-zoomIn{margin:5px 0}.anychart-zoom .anychart-zoom-zoomOut{margin:5px 0}.disable-selection{-moz-user-select:none;-ms-user-select:none;-khtml-user-select:none;-webkit-user-select:none;-webkit-touch-callout:none}.anychart-tooltip{padding:5px 10px;border:none;display:inline-block;box-sizing:border-box;letter-spacing:normal;color:#fff;font-family:Verdana,Helvetica,Arial,"sans-serif";font-size:12px;position:absolute;pointer-events:none;margin:10px 0 10px 10px;background:rgb(0 0 0 / .5);backdrop-filter:blur(10px) saturate(1.5) contrast(.8);border-radius:8px;border:1px solid rgb(0 0 0 / .24)}.anychart-tooltip-separator{height:.1px;background-color:rgb(255 255 255 / .1);margin:6px -6px}.anychart-tooltip-title{font-size:14px}
\ No newline at end of file
+.anychart-ui-support{border-style:hidden}* [class^="anychart"]{outline:none}.anychart-inline-block{position:relative;display:-moz-inline-box;display:inline-block}* html .anychart-inline-block{display:inline}* :first-child+html .anychart-inline-block{display:inline}.anychart-hidden{display:none}.anychart-control-disabled{color:#ccc}.anychart-label-input{-webkit-border-radius:1px;-moz-border-radius:1px;border-radius:1px;border:1px solid #d9d9d9;border-top:1px solid silver;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;font-size:13px;height:16px;padding:5px 4px}.anychart-label-input:focus{border-color:#4d90fe}.anychart-label-input.anychart-label-input-label-disabled{color:#ccc}.anychart-thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:2px;-webkit-transition:border 0.2s ease-in-out;transition:border 0.2s ease-in-out}.anychart-thumbnail>img{margin-right:auto;margin-left:auto;display:block;max-width:100%;height:auto}.anychart-thumbnail:hover,.anychart-thumbnail:focus{border-color:#ccc;box-shadow:0 1px 3px rgb(0 0 0 / .2)}.anychart-thumbnail:active{-webkit-box-shadow:inset 0 1px 1px rgb(0 0 0 / .3);-moz-box-shadow:inset 0 1px 1px rgb(0 0 0 / .3);box-shadow:inset 0 1px 1px rgb(0 0 0 / .3);border-color:#489adc}.anychart-loader{background-color:rgb(255 255 255 / .5);position:absolute;top:0;left:0;width:100%;height:100%;z-index:1000}.anychart-loader .anychart-loader-rotating-cover{width:70px;height:70px;position:absolute;top:50%;margin-top:-35px;left:50%;margin-left:-35px}.anychart-loader .anychart-loader-rotating-plane{display:block;width:100%;height:100%;border-radius:20%;border:5px solid #1c75ba;margin:0 auto;position:relative;-webkit-animation:anychart-loader-rotate-plane 3s infinite;animation:anychart-loader-rotate-plane 3s infinite}.anychart-loader .anychart-loader-chart-row{position:absolute;top:10px;bottom:0;left:10px;right:10px;letter-spacing:-3px;line-height:0;font-size:0;white-space:nowrap}.anychart-loader .anychart-loader-chart-row .anychart-loader-chart-col{display:inline-block;width:25%;height:90%;background:#000;margin:0 12.5% 0 0;vertical-align:bottom}.anychart-loader .anychart-loader-chart-row .anychart-loader-chart-col.anychart-loader-green{background:#26a957;height:50%;-webkit-animation:anychart-loader-blink-plane 1.5s infinite;animation:anychart-loader-blink-plane 1.5s infinite}.anychart-loader .anychart-loader-chart-row .anychart-loader-chart-col.anychart-loader-orange{background:#ff8207;height:70%;-webkit-animation:anychart-loader-blink-plane 1.5s infinite 0.15s;animation:anychart-loader-blink-plane 1.5s infinite 0.25s}.anychart-loader .anychart-loader-chart-row .anychart-loader-chart-col.anychart-loader-red{background:#f0402e;height:90%;-webkit-animation:anychart-loader-blink-plane 1.5s infinite 0.3s;animation:anychart-loader-blink-plane 1.5s infinite 0.5s}@keyframes anychart-loader-rotate-plane{0%{-webkit-transform:perspective(120px) rotateX(0deg) rotateY(0deg);transform:perspective(120px) rotateX(0deg) rotateY(0deg);opacity:1}25%{-webkit-transform:perspective(120px) rotateX(-180.1deg) rotateY(0deg);transform:perspective(120px) rotateX(-180.1deg) rotateY(0deg);opacity:.3}50%{-webkit-transform:perspective(120px) rotateX(-180deg) rotateY(-179.9deg);transform:perspective(120px) rotateX(-180deg) rotateY(-179.9deg);opacity:1}75%{-webkit-transform:perspective(120px) rotateX(0deg) rotateY(-180.1deg);transform:perspective(120px) rotateX(0deg) rotateY(-180.1deg);opacity:.3}100%{-webkit-transform:perspective(120px) rotateX(0deg) rotateY(0deg);transform:perspective(120px) rotateX(0deg) rotateY(0deg);opacity:1}}@keyframes anychart-loader-blink-plane{0%{opacity:1}50%{opacity:.01}100%{opacity:1}}.anychart-custom-button{margin:2px;border:0;padding:0;font-family:Arial,sans-serif;color:#000;background:#ddd url(https://cdn.anychart.com/ACDVF/button-bg.png) repeat-x top left;text-decoration:none;list-style:none;vertical-align:middle;cursor:pointer;outline:none}.anychart-custom-button-outer-box,.anychart-custom-button-inner-box{border-style:solid;border-color:#aaa;vertical-align:top}.anychart-custom-button-outer-box{margin:0;border-width:1px 0;padding:0}.anychart-custom-button-inner-box{margin:0 -1px;border-width:0 1px;padding:3px 4px;white-space:nowrap}* html .anychart-custom-button-inner-box{left:-1px}* html .anychart-custom-button-rtl .anychart-custom-button-outer-box{left:-1px}* html .anychart-custom-button-rtl .anychart-custom-button-inner-box{right:auto}* :first-child+html .anychart-custom-button-inner-box{left:-1px}* :first-child+html .anychart-custom-button-rtl .anychart-custom-button-inner-box{left:1px}::root .anychart-custom-button,::root .anychart-custom-button-outer-box{line-height:0}::root .anychart-custom-button-inner-box{line-height:normal}.anychart-custom-button-disabled{background-image:none!important;opacity:.3;-moz-opacity:.3;filter:alpha(opacity=30);cursor:default}.anychart-custom-button-disabled .anychart-custom-button-outer-box,.anychart-custom-button-disabled .anychart-custom-button-inner-box{color:#333333!important;border-color:#999999!important}* html .anychart-custom-button-disabled{margin:2px 1px!important;padding:0 1px!important}* :first-child+html .anychart-custom-button-disabled{margin:2px 1px!important;padding:0 1px!important}.anychart-custom-button-hover .anychart-custom-button-outer-box,.anychart-custom-button-hover .anychart-custom-button-inner-box{border-color:#9cf #69e #69e #77aaff!important}.anychart-custom-button-active,.anychart-custom-button-checked{background-color:#bbb;background-position:bottom left}.anychart-custom-button-focused .anychart-custom-button-outer-box,.anychart-custom-button-focused .anychart-custom-button-inner-box{border-color:orange}.anychart-custom-button-collapse-right,.anychart-custom-button-collapse-right .anychart-custom-button-outer-box,.anychart-custom-button-collapse-right .anychart-custom-button-inner-box{margin-right:0}.anychart-custom-button-collapse-left,.anychart-custom-button-collapse-left .anychart-custom-button-outer-box,.anychart-custom-button-collapse-left .anychart-custom-button-inner-box{margin-left:0}.anychart-custom-button-collapse-left .anychart-custom-button-inner-box{border-left:1px solid #fff}.anychart-custom-button-collapse-left.anychart-custom-button-checked.anychart-custom-button-inner-box{border-left:1px solid #ddd}* html .anychart-custom-button-collapse-left .anychart-custom-button-inner-box{left:0}* :first-child+html .anychart-custom-button-collapse-left.anychart-custom-button-inner-box{left:0}.anychart-button{-webkit-border-radius:2px;-moz-border-radius:2px;border-radius:2px;background-color:#f5f5f5;background-image:-webkit-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:linear-gradient(to bottom,#f5f5f5,#f1f1f1);border:1px solid #dcdcdc;border:1px solid rgb(0 0 0 / .1);color:#333;cursor:pointer;text-align:center;font-family:inherit;font-size:11px;font-weight:700;height:29px;line-height:27px;margin-right:16px;min-width:52px;outline:0;padding:0 8px;white-space:nowrap}.anychart-button:focus{border-color:#4d90fe}.anychart-button:hover{-webkit-box-shadow:0 1px 1px rgb(0 0 0 / .1);-moz-box-shadow:0 1px 1px rgb(0 0 0 / .1);box-shadow:0 1px 1px rgb(0 0 0 / .1)}.anychart-button:active{-webkit-box-shadow:inset 0 1px 1px rgb(0 0 0 / .3);-moz-box-shadow:inset 0 1px 1px rgb(0 0 0 / .3);box-shadow:inset 0 1px 1px rgb(0 0 0 / .3)}.anychart-button i{font-size:11px}.anychart-button.anychart-button-disabled:active{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;cursor:default}.anychart-button-primary{-webkit-border-radius:2px;-moz-border-radius:2px;border-radius:2px;background-color:#4898e6;background-image:-webkit-linear-gradient(to bottom,#4898e6,#4089d0);background-image:-moz-linear-gradient(to bottom,#4898e6,#4089d0);background-image:-ms-linear-gradient(to bottom,#4898e6,#4089d0);background-image:-o-linear-gradient(to bottom,#4898e6,#4089d0);background-image:linear-gradient(to bottom,#4898e6,#4089d0);border:1px solid #1976d2;color:#fff;cursor:pointer;font-size:11px;font-weight:700;height:26px;line-height:24px;margin:0 16px 0 0;min-width:70px;outline:0;padding:0 7px}.anychart-button-primary:hover,.anychart-button-primary:active{background-color:#4898e6;background-image:-webkit-linear-gradient(to bottom,#4898e6,#387ec3);background-image:-moz-linear-gradient(to bottom,#4898e6,#387ec3);background-image:-ms-linear-gradient(to bottom,#4898e6,#387ec3);background-image:-o-linear-gradient(to bottom,#4898e6,#387ec3);background-image:linear-gradient(to bottom,#4898e6,#387ec3);border:1px solid #1976d2;color:#fff}.anychart-button-primary:focus{-webkit-box-shadow:inset 0 0 0 1px #fff;-moz-box-shadow:inset 0 0 0 1px #fff;box-shadow:inset 0 0 0 1px #fff;outline:0}.anychart-button-primary:active{-webkit-box-shadow:inset 0 1px 2px rgb(0 0 0 / .3);-moz-box-shadow:inset 0 1px 2px rgb(0 0 0 / .3);box-shadow:inset 0 1px 2px rgb(0 0 0 / .3)}.anychart-button-primary.anychart-button-disabled{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;background:#4d90fe;background-image:-webkit-linear-gradient(to bottom,#4d90fe,#4089d0);background-image:-moz-linear-gradient(to bottom,#4d90fe,#4089d0);background-image:-ms-linear-gradient(to bottom,#4d90fe,#4089d0);background-image:-o-linear-gradient(to bottom,#4d90fe,#4089d0);background-image:linear-gradient(to bottom,#4d90fe,#4089d0);filter:alpha(opacity=50);opacity:.5;cursor:default}.anychart-button-secondary{-webkit-border-radius:2px;-moz-border-radius:2px;border-radius:2px;background-color:#f5f5f5;background-image:-webkit-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:linear-gradient(to bottom,#f5f5f5,#f1f1f1);border:1px solid #dcdcdc;color:#333;cursor:pointer;font-size:11px;font-weight:700;height:26px;line-height:24px;margin:0 16px 0 0;min-width:70px;outline:0;padding:0 7px}.anychart-button-secondary:hover,.anychart-button-secondary:active{-webkit-box-shadow:0 1px 1px rgb(0 0 0 / .1);-moz-box-shadow:0 1px 1px rgb(0 0 0 / .1);box-shadow:0 1px 1px rgb(0 0 0 / .1);background-color:#f8f8f8;background-image:-webkit-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:linear-gradient(to bottom,#f8f8f8,#f1f1f1);border:1px solid #c6c6c6;color:#111}.anychart-button-secondary:focus{border:1px solid #4d90fe}.anychart-button-secondary:active{-webkit-box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);-moz-box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);box-shadow:inset 0 1px 2px rgb(0 0 0 / .1)}.anychart-button-secondary.anychart-button-disabled{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;background-color:#f5f5f5;background-image:-webkit-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:linear-gradient(to bottom,#f5f5f5,#f1f1f1);border:1px solid #f3f3f3;border:1px solid rgb(0 0 0 / .05);color:#b8b8b8;cursor:default}.anychart-button-standard{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;background-color:#f5f5f5;background-image:-webkit-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:linear-gradient(to bottom,#f5f5f5,#f1f1f1);color:#333;border:1px solid #dcdcdc;border:1px solid rgb(0 0 0 / .1)}.anychart-button-standard:hover{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;background-color:#f8f8f8;background-image:-webkit-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:linear-gradient(to bottom,#f8f8f8,#f1f1f1);border:1px solid #c6c6c6;color:#111}.anychart-button-standard:active{-webkit-box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);-moz-box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);background:#f8f8f8;color:#111}.anychart-button-standard.anychart-button-checked{-webkit-box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);-moz-box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);background-color:#eee;background-image:-webkit-linear-gradient(to bottom,#eeeeee,#e0e0e0);background-image:-moz-linear-gradient(to bottom,#eeeeee,#e0e0e0);background-image:-ms-linear-gradient(to bottom,#eeeeee,#e0e0e0);background-image:-o-linear-gradient(to bottom,#eeeeee,#e0e0e0);background-image:linear-gradient(to bottom,#eeeeee,#e0e0e0);border:1px solid #ccc;color:#333}.anychart-button-standard.anychart-button-disabled{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;background-color:#f5f5f5;background-image:-webkit-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:linear-gradient(to bottom,#f5f5f5,#f1f1f1);border:1px solid #f3f3f3;border:1px solid rgb(0 0 0 / .05);color:#b8b8b8;cursor:default}.anychart-button-toggle{height:28px;line-height:24px;padding:0;min-width:27px;margin:0;vertical-align:middle}.anychart-button.anychart-button-toggle{z-index:auto}.anychart-button-collapse-left,.anychart-button-collapse-right{z-index:1}.anychart-button-collapse-left.anychart-button-checked,.anychart-button-collapse-right.anychart-button-checked{z-index:2}.anychart-button-collapse-left:hover,.anychart-button-collapse-right:hover{z-index:3}.anychart-button-collapse-left.anychart-button-disabled{z-index:0}.anychart-button-collapse-right{margin-right:0;-moz-border-radius-topright:0;-moz-border-radius-bottomright:0;-webkit-border-top-right-radius:0;-webkit-border-bottom-right-radius:0;border-top-right-radius:0;border-bottom-right-radius:0}.anychart-button-collapse-left{margin-left:-1px;-moz-border-radius-bottomleft:0;-moz-border-radius-topleft:0;-webkit-border-bottom-left-radius:0;-webkit-border-top-left-radius:0;border-bottom-left-radius:0;border-top-left-radius:0}.anychart-menu{-webkit-border-radius:0;-moz-border-radius:0;-webkit-box-shadow:0 2px 4px rgb(0 0 0 / .2);-moz-box-shadow:0 2px 4px rgb(0 0 0 / .2);box-shadow:0 2px 4px rgb(0 0 0 / .2);cursor:default;font-size:13px;font-family:Arial,sans-serif;margin:0;outline:none;position:absolute;z-index:1003;line-height:normal;background:rgb(0 0 0 / .5);backdrop-filter:blur(10px) saturate(1.5);border-radius:8px;border:1px solid rgba(255,2550,255,.24);color:white!important;padding:4px}.anychart-menuitem{position:relative;color:#333;cursor:pointer;list-style:none;margin:0;padding:8px 1em 8px 30px;white-space:nowrap}.anychart-menuitem.anychart-menuitem-rtl{padding-left:7em;padding-right:28px}.anychart-menu-nocheckbox .anychart-menuitem,.anychart-menu-noicon .anychart-menuitem{padding-left:12px}.anychart-menu-noaccel .anychart-menuitem{padding-right:20px}.anychart-menuitem-disabled{cursor:default}.anychart-menuitem-disabled .anychart-menuitem-accel,.anychart-menuitem-disabled .anychart-menuitem-content{color:white!important}.anychart-menuitem-disabled .anychart-menuitem-icon{opacity:.3;-moz-opacity:.3;filter:alpha(opacity=30)}.anychart-menuitem-highlight,.anychart-menuitem-hover{background-color:rgb(255 255 255 / .1)!important;border:none!important;border-radius:4px}.anychart-menuitem-checkbox,.anychart-menuitem-icon{background-repeat:no-repeat;height:21px;left:3px;position:absolute;right:auto;top:3px;vertical-align:middle;width:21px}.anychart-menuitem i{position:absolute;left:9px;color:whitesmoke}.anychart-menuitem-link{padding:0}.anychart-menuitem-link a{text-decoration:none;color:inherit;display:inline-block;padding:6px 3em 6px 28px;width:100%;box-sizing:border-box;transition:none}.anychart-menuitem-link i{padding-top:7px;pointer-events:none}.anychart-menuitem-link.anychart-menuitem-highlight a{padding-bottom:5px;padding-top:5px}.anychart-menuitem-link.anychart-menuitem-highlight i{padding-top:6px}.anychart-menuitem-rtl .anychart-menuitem-checkbox,.anychart-menuitem-rtl .anychart-menuitem-icon{left:auto;right:6px}.anychart-menuitem-accel{color:#777;direction:ltr;left:auto;float:right;padding:0 0 0 24px;position:relative;right:0;text-align:right}.anychart-menuitem-rtl .anychart-menuitem-accel{left:0;right:auto;text-align:left}.anychart-menuitem-mnemonic-hint{text-decoration:underline}.anychart-menuitem-mnemonic-separator{color:#999;font-size:12px;padding-left:4px}.anychart-menuseparator{border-top:.5px solid rgb(255 255 255 / .1);margin:6px -4px}.anychart-submenu-arrow{color:#b3b3b3;opacity:.9;filter:alpha(opacity=50);position:absolute;right:-20px;top:3px;border-top:5px solid #fff0;border-bottom:5px solid #fff0;border-left:5px solid #b3b3b3;font-size:0}.anychart-menuitem-content{position:relative;color:#fff}.anychart-menuitem-highlight .anychart-submenu-arrow,.anychart-menuitem-hover .anychart-submenu-arrow{opacity:1}.anychart-menuitem-rtl .anychart-submenu-arrow{text-align:left;left:0;right:auto;padding-left:6px}.anychart-menuitem-disabled .anychart-submenu-arrow{border-left-color:#ccc;opacity:1}.anychart-menu-scrollable{overflow-y:auto}.anychart-menu.anychart-menu-horizontal{padding-left:4px;padding-right:4px}.anychart-menu.anychart-menu-horizontal .anychart-menuitem{display:inline-block;padding:2px 3px}.anychart-menu.anychart-menu-horizontal .anychart-menuitem.anychart-menuitem-highlight,.anychart-menu.anychart-menu-horizontal .anychart-menuitem .anychart-menuitem-hover{padding-top:1px;padding-bottom:1px}.anychart-menuitem.anychart-option i{position:relative;left:1px;height:23px;padding-top:1px;width:23px}.anychart-menuitem.anychart-option i.ac-position-center{top:-1px}.anychart-menu-button{background:#ddd url(https://cdn.anychart.com/ACDVF/button-bg.png) repeat-x top left;border:0;color:#000;cursor:pointer;list-style:none;margin:2px;outline:none;padding:0;text-decoration:none;vertical-align:middle}.anychart-menu-button-outer-box,.anychart-menu-button-inner-box{border-style:solid;border-color:#aaa;vertical-align:top}.anychart-menu-button-outer-box{margin:0;border-width:1px 0;padding:0}.anychart-menu-button-inner-box{margin:0 -1px;border-width:0 1px;padding:3px 4px}* html .anychart-menu-button-inner-box{left:-1px}* html .anychart-menu-button-rtl .anychart-menu-button-outer-box{left:-1px;right:auto}* html .anychart-menu-button-rtl .anychart-menu-button-inner-box{right:auto}* :first-child+html .anychart-menu-button-inner-box{left:-1px}* :first-child+html .anychart-menu-button-rtl .anychart-menu-button-inner-box{left:1px;right:auto}::root .anychart-menu-button,::root .anychart-menu-button-outer-box,::root .anychart-menu-button-inner-box{line-height:0}::root .anychart-menu-button-caption,::root .anychart-menu-button-dropdown{line-height:normal}.anychart-menu-button-disabled{background-image:none!important;opacity:.3;-moz-opacity:.3;filter:alpha(opacity=30);cursor:default}.anychart-menu-button-disabled .anychart-menu-button-outer-box,.anychart-menu-button-disabled .anychart-menu-button-inner-box,.anychart-menu-button-disabled .anychart-menu-button-caption,.anychart-menu-button-disabled .anychart-menu-button-dropdown{color:#333333!important;border-color:#999999!important}* html .anychart-menu-button-disabled{margin:2px 1px!important;padding:0 1px!important}* :first-child+html .anychart-menu-button-disabled{margin:2px 1px!important;padding:0 1px!important}.anychart-menu-button-hover .anychart-menu-button-outer-box,.anychart-menu-button-hover .anychart-menu-button-inner-box{border-color:#9cf #69e #69e #77aaff!important}.anychart-menu-button-active,.anychart-menu-button-open{background-color:#bbb;background-position:bottom left}.anychart-menu-button-focused .anychart-menu-button-outer-box,.anychart-menu-button-focused .anychart-menu-button-inner-box{border-color:orange}.anychart-menu-button-caption{padding:0 4px 0 0;vertical-align:top}.anychart-menu-button-dropdown{height:15px;width:7px;background:url(https://cdn.anychart.com/ACDVF/editortoolbar.png) no-repeat -388px 0;vertical-align:top}.anychart-menu-button-collapse-right,.anychart-menu-button-collapse-right .anychart-menu-button-outer-box,.anychart-menu-button-collapse-right .anychart-menu-button-inner-box{margin-right:0}.anychart-menu-button-collapse-left,.anychart-menu-button-collapse-left .anychart-menu-button-outer-box,.anychart-menu-button-collapse-left .anychart-menu-button-inner-box{margin-left:0}.anychart-menu-button-collapse-left .anychart-menu-button-inner-box{border-left:1px solid #fff}.anychart-menu-button-collapse-left.anychart-menu-button-checked.anychart-menu-button-inner-box{border-left:1px solid #ddd}.anychart-flat-menu-button{-webkit-border-radius:2px;-moz-border-radius:2px;border-radius:2px;background-color:#f5f5f5;background-image:-webkit-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:linear-gradient(to bottom,#f5f5f5,#f1f1f1);-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;border:1px solid #dcdcdc;color:#333;cursor:pointer;font-size:11px;font-weight:700;line-height:27px;list-style:none;margin:0 2px;min-width:46px;outline:none;padding:0 18px 0 6px;text-align:center;text-decoration:none}.anychart-flat-menu-button-disabled{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;background-color:#f5f5f5;background-image:-webkit-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f5f5f5,#f1f1f1);background-image:linear-gradient(to bottom,#f5f5f5,#f1f1f1);border:1px solid #f3f3f3;border:1px solid rgb(0 0 0 / .05);color:#b8b8b8;cursor:default}.anychart-flat-menu-button-disabled .anychart-flat-menu-button-dropdown{border-color:#b8b8b8 #fff0}.anychart-flat-menu-button-hover{background-color:#f8f8f8;background-image:-webkit-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:linear-gradient(to bottom,#f8f8f8,#f1f1f1);-webkit-box-shadow:0 1px 1px rgb(0 0 0 / .1);-moz-box-shadow:0 1px 1px rgb(0 0 0 / .1);box-shadow:0 1px 1px rgb(0 0 0 / .1);border-color:#c6c6c6;color:#111}.anychart-flat-menu-button.anychart-flat-menu-button-open,.anychart-flat-menu-button.anychart-flat-menu-button-active{-webkit-box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);-moz-box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);box-shadow:inset 0 1px 2px rgb(0 0 0 / .1);background-color:#eee;background-image:-webkit-linear-gradient(to bottom,#eeeeee,#e0e0e0);background-image:-moz-linear-gradient(to bottom,#eeeeee,#e0e0e0);background-image:-ms-linear-gradient(to bottom,#eeeeee,#e0e0e0);background-image:-o-linear-gradient(to bottom,#eeeeee,#e0e0e0);background-image:linear-gradient(to bottom,#eeeeee,#e0e0e0);border:1px solid #ccc;color:#333;z-index:2}.anychart-flat-menu-button-focused{border-color:#4d90fe}.anychart-flat-menu-button-caption{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;white-space:nowrap;padding-right:20px;text-overflow:ellipsis;overflow:hidden;width:100%}.anychart-flat-menu-button-dropdown{border-color:#777 #fff0;border-style:solid;border-width:4px 4px 0 4px;height:0;width:0;position:absolute;right:5px;top:12px}.anychart-flat-menu-button-active .anychart-flat-menu-button-dropdown,.anychart-flat-menu-button-open .anychart-flat-menu-button-dropdown,.anychart-flat-menu-button-selected .anychart-flat-menu-button-dropdown,.anychart-flat-menu-button-hover .anychart-flat-menu-button-dropdown{border-color:#595959 #fff0}.anychart-combobox{background:0;background-color:whiteSmoke;background-image:-webkit-linear-gradient(to bottom,whiteSmoke,#f1f1f1);background-image:-moz-linear-gradient(to bottom,whiteSmoke,#f1f1f1);background-image:-ms-linear-gradient(to bottom,whiteSmoke,#f1f1f1);background-image:-o-linear-gradient(to bottom,whiteSmoke,#f1f1f1);background-image:linear-gradient(to bottom,whiteSmoke,#f1f1f1);border:1px solid gainsboro;border:1px solid rgb(0 0 0 / .1);-webkit-border-radius:2px;-moz-border-radius:2px;border-radius:2px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;font:normal small arial,sans-serif;height:24px;color:#333;line-height:24px;list-style:none;font-size:11px;font-weight:700;text-decoration:none;vertical-align:middle;cursor:pointer;margin-left:4px;padding:1px 0;top:auto}.anychart-combobox:hover{-webkit-box-shadow:0 1px 1px rgb(0 0 0 / .1);-moz-box-shadow:0 1px 1px rgb(0 0 0 / .1);box-shadow:0 1px 1px rgb(0 0 0 / .1);background-color:#f8f8f8;background-image:-webkit-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-moz-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-ms-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:-o-linear-gradient(to bottom,#f8f8f8,#f1f1f1);background-image:linear-gradient(to bottom,#f8f8f8,#f1f1f1);border-color:#c6c6c6;color:#222}.anychart-combobox:hover input{border-right-color:#d9d9d9}.anychart-combobox.anychart-combobox-disabled{background-color:whiteSmoke;background-image:-webkit-linear-gradient(to bottom,whiteSmoke,#f1f1f1);background-image:-moz-linear-gradient(to bottom,whiteSmoke,#f1f1f1);background-image:-ms-linear-gradient(to bottom,whiteSmoke,#f1f1f1);background-image:-o-linear-gradient(to bottom,whiteSmoke,#f1f1f1);background-image:linear-gradient(to bottom,whiteSmoke,#f1f1f1);border:1px solid #f2f2f2;opacity:1;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;cursor:default}.anychart-combobox.anychart-combobox-disabled input{color:#b8b8b8;border-right-color:#fff0}.anychart-combobox.anychart-combobox-disabled .anychart-combobox-button{opacity:.4}.anychart-combobox input{-webkit-border-radius:1px;-moz-border-radius:1px;border-radius:1px;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;background:#fff0;border:1px solid #fff0;color:#333;font-family:Arial,sans-serif;font-size:11px;font-weight:700;height:20px;overflow:hidden;padding:0 0 0 3px;position:relative;margin-right:18px}.anychart-combobox input:focus{-webkit-box-shadow:inset 0 1px 2px rgb(0 0 0 / .3);-moz-box-shadow:inset 0 1px 2px rgb(0 0 0 / .3);box-shadow:inset 0 1px 2px rgb(0 0 0 / .3);-webkit-user-select:text;-moz-user-select:text;background:#fff;border:1px solid #4d90fe;height:20px;outline:none}.anychart-combobox-button{display:inline-block;border-color:#777 #fff0;border-style:solid;border-width:4px 4px 0 4px;height:0;width:0;position:absolute;right:5px;top:12px}.anychart-checkbox{cursor:pointer;display:inline-block;margin:2px 2px 2px 0;overflow:hidden;padding:0;position:relative;white-space:nowrap;vertical-align:middle}.anychart-checkbox .anychart-checkbox-element{-webkit-border-radius:1px;-moz-border-radius:1px;border-radius:1px;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;background-color:rgb(255 255 255 / .05);border:1px solid #c6c6c6;border:1px solid rgb(155 155 155 / .57);height:11px;margin:0 4px 1px 1px;outline:0;vertical-align:text-bottom;width:11px}.anychart-checkbox-hover .anychart-checkbox-element{-webkit-box-shadow:inset 0 1px 1px rgb(0 0 0 / .1);-moz-box-shadow:inset 0 1px 1px rgb(0 0 0 / .1);box-shadow:inset 0 1px 1px rgb(0 0 0 / .1);border:1px solid #b2b2b2}.anychart-checkbox-focused .anychart-checkbox-element{border:1px solid #4d90fe}.anychart-checkbox-checked{background-color:#fff;background-color:rgb(255 255 255 / .65)}.anychart-checkbox-checked .anychart-checkbox-checkmark{display:inline-block}.anychart-checkbox-disabled{cursor:default;color:#b8b8b8}.anychart-checkbox-disabled .anychart-checkbox-element{background-color:#fff;border:1px solid #f1f1f1;cursor:default}.anychart-checkbox-checkmark{display:none;opacity:.6;height:15px;outline:0;width:15px;left:0;position:relative;top:-3px}.anychart-palette{cursor:default;outline:none}.anychart-palette-table{empty-cells:show;margin:16px}.anychart-palette-cell{border:1px solid #fff0;cursor:pointer;margin:0;position:relative}.anychart-plot-controls{position:absolute}.anychart-plot-controls .anychart-button{min-width:20px;height:20px;opacity:.7;line-height:0;vertical-align:middle;margin:0;padding:0;display:inline-block}.anychart-plot-controls-hidden{visibility:hidden}.anychart-palette-colorswatch{border:none;font-size:14px;height:16px;position:relative;width:16px;display:block}.anychart-palette-colorswatch:before{display:none;position:relative;top:1px;left:2px}.anychart-palette-cell-hover{border:1px solid #000}.anychart-palette-cell-selected{outline:1px solid #000}.anychart-palette-cell-selected .anychart-palette-colorswatch:before{display:inline-block}.anychart-color-menu-button-indicator{height:14px;margin-left:4px;outline:1px solid #bbb;width:14px}.anychart-color-menu-button .anychart-menu-button-inner-box,.anychart-toolbar-color-menu-button .anychart-toolbar-menu-button-inner-box{padding-top:2px!important;padding-bottom:2px!important}.anychart-toolbar{background-color:#f7f7f7;border:1px solid #d5d5d5;cursor:default;font:normal 12px Verdana,sans-serif;color:#7c868e;padding:2px;position:relative}.anychart-toolbar-button,.anychart-toolbar-menu-button{margin:0 2px;border:0;padding:2px 2px;text-decoration:none;vertical-align:middle;list-style:none;cursor:default;outline:none}.anychart-toolbar-button-inner-box,.anychart-toolbar-menu-button-inner-box{padding:3px 4px}.anychart-toolbar-button-hover,.anychart-toolbar-menu-button-hover{background-color:#eee}.anychart-toolbar-menu-button-active,.anychart-toolbar-menu-button-open,.anychart-toolbar-button-active,.anychart-toolbar-button-checked,.anychart-toolbar-button-selected{background-color:#dddddd!important}.anychart-toolbar-menu-button-dropdown{margin-left:3px;padding-bottom:1px;width:0;height:0;border-left:4px solid #fff0;border-right:4px solid #fff0;border-top:4px solid #7c868e;vertical-align:middle}.anychart-toolbar-separator{margin:0 2px;border-left:1px solid #d6d6d6;border-right:1px solid #f7f7f7;padding:0;width:0;text-decoration:none;list-style:none;outline:none;vertical-align:middle;line-height:normal;font-size:120%;overflow:hidden}.anychart-toolbar-item-icon{padding:0 2px;font-size:13px;color:#2485d0}.anychart-toolbar-item-text{padding:0 2px}.anychart-toolbar-menu{background-color:#f7f7f7;border:1px solid #d5d5d5;cursor:default;font:normal 12px Verdana,sans-serif;color:#7c868e;margin:0;outline:none;padding:6px 0;position:absolute;z-index:1003;line-height:normal}.anychart-toolbar-menu .anychart-menuitem{color:#7c868e}.anychart-toolbar-menu-button-hover .anychart-toolbar-menu-button-dropdown,.anychart-toolbar-menu-button-open .anychart-toolbar-menu-button-dropdown{border-top:4px solid #000}.anychart-toolbar-menu .anychart-menuitem i,.anychart-toolbar-menu .anychart-menuitem-content{display:inline;position:static}.anychart-toolbar-menu .anychart-menuitem{padding:6px 5px;padding-right:8px;border-style:none}.anychart-toolbar-menu .anychart-submenu-arrow{position:absolute;right:0;top:9px;padding:0 2px}.anychart-menuitem-content{margin-right:1rem}.anychart-option[role="menuitemradio"]{padding-left:1.5rem}.anychart-option-selected .anychart-menuitem-checkbox:before{content:"\2713";display:block;width:100%;height:100%;text-align:center}.anychart-range-picker{font-family:Arial,sans-serif;font-size:11px;color:#7c868e}.anychart-range-picker.anychart-range-picker-inside{position:absolute;bottom:27px;left:20px}.anychart-range-picker .anychart-label-input{font-size:11px;margin:0 5px;height:13px;padding:3px 4px;width:90px;font-weight:400}.anychart-range-picker .anychart-input-label{margin-bottom:0;font-weight:400}.anychart-range-selector{font-family:Arial,sans-serif;font-size:11px;color:#7c868e}.anychart-range-selector.anychart-range-selector-inside{position:absolute;bottom:27px;right:25px}.anychart-range-selector .anychart-input-label{margin-right:5px;vertical-align:middle;margin-bottom:0;font-weight:400}.anychart-range-selector .anychart-button{height:21px;line-height:19px;padding:0 5px}.anychart-zoom{position:absolute;left:15px;top:10px}.anychart-zoom .anychart-button{min-width:21px;padding:0;line-height:normal;display:block;margin:5px 0;height:26px;width:26px;margin:5px 0;backdrop-filter:blur(10px) contrast(.8);border-radius:5px;background:rgb(255 255 255 / .125);border:1px solid rgb(255 255 255 / .18);color:#fff}.anychart-zoom .anychart-zoom-zoomIn{margin:5px 0}.anychart-zoom .anychart-zoom-zoomOut{margin:5px 0}.disable-selection{-moz-user-select:none;-ms-user-select:none;-khtml-user-select:none;-webkit-user-select:none;-webkit-touch-callout:none}.anychart-tooltip{padding:5px 10px;border:none;display:inline-block;box-sizing:border-box;letter-spacing:normal;color:#fff;font-family:Verdana,Helvetica,Arial,"sans-serif";font-size:12px;position:absolute;pointer-events:none;margin:10px 0 10px 10px;background:rgb(0 0 0 / .5);backdrop-filter:blur(10px) saturate(1.5) contrast(.8);border-radius:8px;border:1px solid rgb(0 0 0 / .24)}.anychart-tooltip-separator{height:.1px;background-color:rgb(255 255 255 / .1);margin:6px -6px}.anychart-tooltip-title{font-size:14px}#countryChart path[fill="#F7F7F7"]{fill:rgb(35 45 59 / .9)!important;stroke:rgb(65 73 84 / 60%)!important}#countryChart path[fill*="#2d1b3d"],#countryChart path[fill*="#8b5cf6"],#countryChart path[fill*="#a855f7"],#countryChart path[fill*="#c084fc"]{opacity:0.8!important;stroke:rgb(139 92 246 / .9)!important;stroke-width:1px!important}#countryChart path[fill^="#"]:not([fill="#F7F7F7"]){opacity:0.75!important}#countryChart path:hover{stroke-width:1.5px!important}#countryChart path[fill*="rgba(31, 41, 55"]:hover{fill:rgb(55 65 81 / .9)!important;stroke:rgb(139 92 246 / .4)!important;filter:none!important}
\ No newline at end of file
diff --git a/static/css/api.css b/static/css/api.css
index 6857d40b..2b9b9ff0 100644
--- a/static/css/api.css
+++ b/static/css/api.css
@@ -40,6 +40,8 @@ body {
color: rgb(212, 212, 212);
}
+/* Auth modal styles were moved to static/css/auth.css */
+
.emoji {
width: 8.5rem;
height: 8.5rem;
diff --git a/static/css/auth.css b/static/css/auth.css
new file mode 100644
index 00000000..53160748
--- /dev/null
+++ b/static/css/auth.css
@@ -0,0 +1,110 @@
+/* Auth modal dedicated styles */
+#authModal.modal {
+ display: none;
+ align-items: center;
+ justify-content: center;
+}
+
+#authModal .modal-content {
+ width: 100%;
+ max-width: 460px;
+ margin: 70px 20px;
+ padding: 24px 22px;
+ border-radius: 14px;
+ background: rgba(15, 18, 37, 0.95);
+ border: 1px solid #2a2e52;
+ box-shadow: 0 20px 60px rgba(0,0,0,0.45);
+}
+
+#authModal h2 {
+ margin: 12px 0;
+ color: #e9e9ff;
+ font-size: 28px;
+ text-align: center;
+}
+
+#authModal label {
+ display: block;
+ margin-top: 12px;
+ margin-bottom: 6px;
+ color: #c7c9ff;
+}
+
+#authModal input {
+ width: 100%;
+ padding: 12px 14px;
+ border: 1px solid #2a2e52;
+ border-radius: 10px;
+ background: #0b0e20;
+ color: #fff;
+ backdrop-filter: none;
+}
+
+/* Unify placeholder styling across email, password, and text inputs in the auth modal */
+#authModal input::placeholder,
+#authModal input::-webkit-input-placeholder,
+#authModal input::-moz-placeholder,
+#authModal input:-ms-input-placeholder,
+#authModal input::-ms-input-placeholder {
+ color: rgba(255, 255, 255, 0.6);
+ opacity: 1;
+}
+
+#authModal .actions {
+ display: flex;
+ gap: 12px;
+ margin-top: 18px;
+ align-items: center;
+ justify-content: center;
+}
+
+#authModal .primary {
+ padding: 12px 16px;
+ background: #5865f2;
+ color: #fff;
+ border: none;
+ border-radius: 10px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+}
+
+#authModal .secondary {
+ padding: 10px 14px;
+ background: transparent;
+ color: #c7c9ff;
+ border: 1px solid #2a2e52;
+ border-radius: 10px;
+ cursor: pointer;
+}
+
+#authModal .toggle {
+ margin-top: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+#authModal .toggle button {
+ background: none;
+ border: none;
+ color: #8aa0ff;
+ cursor: pointer;
+ display: inline;
+ width: auto;
+ max-width: none;
+ height: auto;
+ margin: 0;
+ padding: 0;
+ text-decoration: underline;
+ font: inherit;
+}
+#authError { color: #ff7b7b; text-align: center; }
+
+@media screen and (max-width: 600px){
+ #authModal .modal-content { margin: 20px; padding: 20px; }
+}
+
+
diff --git a/static/css/dashboard.css b/static/css/dashboard.css
new file mode 100644
index 00000000..04367437
--- /dev/null
+++ b/static/css/dashboard.css
@@ -0,0 +1,531 @@
+:root {
+ --bg: #090d1a;
+ --glass: rgba(15, 20, 40, 0.52);
+ --glass-2: rgba(255, 255, 255, 0.06);
+ --border: rgba(255, 255, 255, 0.10);
+ --border-2: rgba(255, 255, 255, 0.18);
+ --text: #ffffff;
+ --muted: rgba(255, 255, 255, 0.70);
+ --accent: #7c3aed;
+ --accent-2: #2563eb;
+ --success: #22c55e;
+ --warning: #f59e0b;
+ --danger: #ef4444;
+ --radius-lg: 16px;
+ --radius-md: 12px;
+ --radius-sm: 10px;
+ --shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
+}
+
+body {
+ background: radial-gradient(1000px 600px at -10% -10%, rgba(124, 58, 237, 0.18), transparent 60%),
+ radial-gradient(900px 600px at 110% 0%, rgba(37, 99, 235, 0.16), transparent 60%),
+ linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0));
+ background-color: var(--bg);
+}
+
+.dashboard {
+ display: grid;
+ grid-template-columns: 260px 1fr;
+ gap: 20px;
+ max-width: 100%;
+ margin: 28px auto;
+ padding: 0 24px;
+}
+
+.sidebar {
+ position: sticky;
+ top: 18px;
+ height: calc(100vh - 56px);
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
+ backdrop-filter: blur(14px) saturate(1.1);
+ box-shadow: var(--shadow);
+ padding: 16px;
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 8px 14px 8px;
+ border-bottom: 1px solid var(--border);
+}
+
+.logo-circle {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
+ display: grid;
+ place-items: center;
+ color: #fff;
+ font-weight: 700;
+ letter-spacing: 0.4px;
+}
+
+.brand-text .title {
+ color: var(--text);
+ font-weight: 700;
+ letter-spacing: 0.2px;
+}
+
+.brand-text .subtitle {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.nav {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 8px 0;
+}
+
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ border-radius: var(--radius-sm);
+ color: var(--text);
+ text-decoration: none;
+ border: 1px solid transparent;
+ transition: transform .15s ease, border-color .2s ease, background .2s ease;
+}
+
+.nav-item .dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ background: var(--border-2);
+}
+
+.nav-item:hover {
+ background: var(--glass-2);
+ border-color: var(--border);
+}
+
+.nav-item.active {
+ background: linear-gradient(135deg, rgba(124, 58, 237, 0.18), rgba(37, 99, 235, 0.18));
+ border-color: rgba(124, 58, 237, 0.35);
+}
+
+.nav-item.active .dot {
+ background: #a78bfa;
+}
+
+.nav-item.disabled {
+ opacity: .6;
+ cursor: not-allowed;
+}
+
+.sidebar-footer {
+ margin-top: auto;
+ padding-top: 10px;
+ border-top: 1px solid var(--border);
+}
+
+.content {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-width: 0;
+}
+
+.content-header h1 {
+ margin: 0;
+ color: var(--text);
+ font-size: 22px;
+}
+
+.content-header p {
+ margin: 2px 0 0;
+ color: var(--muted);
+}
+
+.surface {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
+ backdrop-filter: blur(12px) saturate(1.05);
+ box-shadow: var(--shadow);
+}
+
+.toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px;
+ position: relative;
+ z-index: 10;
+ overflow: visible;
+}
+
+.filters {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 10px;
+ align-items: end;
+ width: 100%;
+}
+
+.filters.compact {
+ grid-template-columns: 1fr;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+/* Segmented control */
+.seg {
+ position: relative;
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: 1fr;
+ gap: 8px;
+ padding: 6px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.seg.seg--3 {
+ grid-template-columns: repeat(3, 1fr);
+}
+
+.seg.seg--2 {
+ grid-template-columns: repeat(2, 1fr);
+}
+
+.seg button {
+ position: relative;
+ z-index: 1;
+ border: 0;
+ background: transparent;
+ color: var(--text);
+ padding: 8px 10px;
+ border-radius: 999px;
+ cursor: pointer;
+ font-weight: 600;
+ letter-spacing: .3px;
+}
+
+.seg .seg-indicator {
+ position: absolute;
+ z-index: 0;
+ top: 6px;
+ bottom: 6px;
+ left: 6px;
+ width: var(--seg-w, calc((100% - 16px) / 3));
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--border-2);
+ transition: transform .2s ease;
+}
+
+.seg.seg--2 .seg-indicator {
+ width: calc((100% - 14px) / 2);
+}
+
+.seg[data-active="0"] .seg-indicator {
+ transform: translateX(0%);
+}
+
+.seg[data-active="1"] .seg-indicator {
+ transform: translateX(100%);
+}
+
+.seg[data-active="2"] .seg-indicator {
+ transform: translateX(200%);
+}
+
+.field label {
+ color: var(--muted);
+ font-size: 12px;
+ letter-spacing: .2px;
+}
+
+.field input,
+.field select {
+ width: 100%;
+ padding: 10px 12px;
+ color: var(--text);
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-2);
+ border-radius: 12px;
+}
+
+.field.search input {
+ padding-left: 12px;
+}
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 10px 14px;
+ border-radius: 12px;
+ border: 1px solid var(--border-2);
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--text);
+ cursor: pointer;
+ transition: transform .12s ease, background .2s ease, border-color .2s ease;
+}
+
+/* Options button caret alignment */
+#btn-options {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+#btn-options .caret {
+ line-height: 1;
+ display: inline-block;
+ transform: translateY(1px);
+}
+
+.btn:hover {
+ background: rgba(255, 255, 255, 0.12);
+ border-color: rgba(255, 255, 255, 0.28);
+}
+
+.btn:active {
+ transform: translateY(1px);
+}
+
+.btn-ghost {
+ background: transparent;
+ border-color: var(--border);
+}
+
+.btn-primary {
+ border: none;
+ background: linear-gradient(135deg, var(--accent-2), var(--accent));
+}
+
+.btn-primary:hover {
+ filter: brightness(1.05);
+}
+
+/* Options dropdown */
+.options {
+ position: relative;
+}
+
+.options-dropdown {
+ position: absolute;
+ top: calc(100% + 10px);
+ right: 0;
+ left: auto;
+ min-width: 720px;
+ max-width: 86vw;
+ padding: 20px;
+ background: rgba(15, 20, 35, 0.98);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 12px;
+ backdrop-filter: blur(16px);
+ box-shadow: rgba(0, 0, 0, 0.3) 0 20px 40px 0px;
+ z-index: 1000;
+}
+
+.options-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(220px, 1fr));
+ gap: 16px 18px;
+ align-items: end;
+}
+
+.options-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 10px;
+}
+
+.list-container {
+ padding: 8px;
+ overflow: visible;
+}
+
+.loading,
+.empty {
+ padding: 26px;
+ color: var(--muted);
+ text-align: center;
+}
+
+.links {
+ display: grid;
+ gap: 10px;
+}
+
+.link-item {
+ display: grid;
+ grid-template-columns: 1.4fr .9fr .9fr;
+ gap: 12px;
+ padding: 14px;
+ border-radius: 14px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.03);
+ transition: transform .15s ease, background .2s ease, border-color .2s ease;
+}
+
+.link-item:hover {
+ background: rgba(255, 255, 255, 0.06);
+ border-color: rgba(255, 255, 255, 0.18);
+ transform: translateY(-1px);
+}
+
+.link-main {
+ display: grid;
+ gap: 6px;
+}
+
+.link-short {
+ color: #bfdbfe;
+ text-decoration: none;
+ width: fit-content;
+}
+
+.link-long {
+ color: var(--muted);
+ word-break: break-all;
+ font-size: 13px;
+}
+
+.link-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-content: start;
+}
+
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ border: 1px solid var(--border-2);
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--text);
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.chip.status.ACTIVE {
+ background: rgba(34, 197, 94, 0.15);
+ border-color: rgba(34, 197, 94, 0.32);
+ color: #86efac;
+}
+
+.link-stats {
+ display: grid;
+ grid-auto-flow: column;
+ gap: 12px;
+ align-content: start;
+}
+
+.stat {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.label {
+ color: var(--muted);
+ font-size: 11px;
+ letter-spacing: .3px;
+ text-transform: uppercase;
+}
+
+.value {
+ color: var(--text);
+ font-size: 13px;
+}
+
+.pagination-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 10px 20px 10px 10px;
+}
+
+.pager {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.pager .btn {
+ padding: 8px 12px;
+}
+
+.page-info {
+ color: var(--muted);
+}
+
+.fade-in {
+ animation: fade-in .24s ease both;
+}
+
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ transform: translateY(2px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@media (max-width: 1024px) {
+ .options-dropdown {
+ min-width: 560px;
+ }
+
+ .options-grid {
+ grid-template-columns: repeat(2, minmax(160px, 1fr));
+ }
+
+ .link-item {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 720px) {
+ .dashboard {
+ grid-template-columns: 1fr;
+ }
+
+ .sidebar {
+ position: static;
+ height: auto;
+ }
+
+ .options-dropdown {
+ position: fixed;
+ right: 12px;
+ left: 12px;
+ top: 80px;
+ min-width: auto;
+ }
+
+ .options-grid {
+ grid-template-columns: 1fr;
+ }
+}
\ No newline at end of file
diff --git a/static/css/dashboard/billing.css b/static/css/dashboard/billing.css
new file mode 100644
index 00000000..3d9cef3f
--- /dev/null
+++ b/static/css/dashboard/billing.css
@@ -0,0 +1,181 @@
+/* Billing Page Styles */
+
+.billing-container {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 2rem 1rem;
+}
+
+/* Current Plan Card */
+.current-plan-card {
+ padding: 2rem;
+ border-radius: 12px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+}
+
+.plan-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 1.5rem;
+ padding-bottom: 1.5rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.plan-info .plan-name {
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 0 0 0.5rem 0;
+}
+
+.plan-info .plan-description {
+ color: var(--text-secondary);
+ margin: 0;
+}
+
+.plan-badge .badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ border-radius: 20px;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.badge-active {
+ background: rgba(16, 185, 129, 0.1);
+ color: #10b981;
+}
+
+.plan-details {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 1.5rem;
+ margin-bottom: 2rem;
+}
+
+.detail-item {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.detail-item > i {
+ font-size: 1.5rem;
+ color: var(--accent-primary);
+ opacity: 0.8;
+}
+
+.detail-content {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.detail-label {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+}
+
+.detail-value {
+ font-size: 1rem;
+ font-weight: 500;
+ color: var(--text-primary);
+}
+
+/* Plan Features Section */
+.plan-features {
+ padding-top: 2rem;
+ border-top: 1px solid var(--border);
+}
+
+.features-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 0 0 1.5rem 0;
+}
+
+.features-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 1.5rem;
+}
+
+.features-grid .feature-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 1rem;
+ padding: 1rem;
+ border-radius: 8px;
+ background: rgba(99, 102, 241, 0.05);
+ transition: all 0.3s ease;
+}
+
+.features-grid .feature-item:hover {
+ background: rgba(99, 102, 241, 0.1);
+ transform: translateY(-2px);
+}
+
+.features-grid .feature-item > i {
+ font-size: 1.5rem;
+ color: var(--accent-primary);
+ flex-shrink: 0;
+ margin-top: 0.25rem;
+}
+
+.feature-content {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.feature-title {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.feature-desc {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ line-height: 1.4;
+}
+
+/* Responsive Design */
+@media (max-width: 768px) {
+ .billing-container {
+ padding: 1rem;
+ }
+
+ .current-plan-card {
+ padding: 1.5rem;
+ }
+
+ .plan-header {
+ flex-direction: column;
+ gap: 1rem;
+ }
+
+ .plan-details {
+ grid-template-columns: 1fr;
+ }
+
+ .features-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Dark mode adjustments */
+@media (prefers-color-scheme: dark) {
+ .features-grid .feature-item {
+ background: rgba(99, 102, 241, 0.08);
+ }
+
+ .features-grid .feature-item:hover {
+ background: rgba(99, 102, 241, 0.15);
+ }
+}
diff --git a/static/css/dashboard/dashboard-base.css b/static/css/dashboard/dashboard-base.css
new file mode 100644
index 00000000..9e2c71d9
--- /dev/null
+++ b/static/css/dashboard/dashboard-base.css
@@ -0,0 +1,658 @@
+/* Dashboard Layout Variables */
+:root {
+ --sidebar-width: 260px;
+ --sidebar-collapsed-width: 80px;
+ --dashboard-bg: #090d1a;
+ --sidebar-bg: rgba(15, 20, 40, 0.52);
+ --border-color: rgba(255, 255, 255, 0.10);
+ --text-primary: #ffffff;
+ --text-secondary: rgba(255, 255, 255, 0.70);
+ --accent-primary: #7c3aed;
+ --accent-secondary: #2563eb;
+ --hover-bg: rgba(255, 255, 255, 0.08);
+ --active-bg: rgba(124, 58, 237, 0.15);
+ --radius-lg: 16px;
+ --radius-md: 12px;
+ --radius-sm: 8px;
+ --transition-speed: 0.2s;
+ --transition-easing: cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+/* Reset body for dashboard */
+body.dashboard-layout {
+ margin: 0;
+ padding: 0;
+ font-family: 'Nata Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
+ font-optical-sizing: auto;
+ font-weight: 400;
+ background: radial-gradient(1000px 600px at -10% -10%, rgba(124, 58, 237, 0.18), transparent 60%),
+ radial-gradient(900px 600px at 110% 0%, rgba(37, 99, 235, 0.16), transparent 60%),
+ linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0));
+ background-color: var(--dashboard-bg);
+ min-height: 100vh;
+ overflow-x: hidden;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* Dashboard Wrapper */
+.dashboard-wrapper {
+ display: flex;
+ min-height: 100vh;
+ position: relative;
+}
+
+/* Sidebar Styles */
+.dashboard-sidebar {
+ width: var(--sidebar-width);
+ height: 100vh;
+ position: fixed;
+ left: 0;
+ top: 0;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
+ backdrop-filter: blur(20px) saturate(1.2);
+ border-right: 1px solid var(--border-color);
+ display: flex;
+ flex-direction: column;
+ transition: width var(--transition-speed) var(--transition-easing);
+ z-index: 100;
+}
+
+.dashboard-sidebar.collapsed {
+ width: var(--sidebar-collapsed-width);
+}
+
+/* Sidebar Header */
+.sidebar-header {
+ padding: 20px;
+ border-bottom: 1px solid var(--border-color);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.sidebar-brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ text-decoration: none;
+ color: var(--text-primary);
+ transition: opacity 0.2s;
+}
+
+.sidebar-brand:hover {
+ opacity: 0.8;
+}
+
+.brand-logo {
+ transition: opacity var(--transition-speed) var(--transition-easing), width var(--transition-speed) var(--transition-easing);
+}
+
+.brand-logo img {
+ /* width: 32px; */
+ height: 26px;
+ margin-top: 5px;
+ flex-shrink: 0;
+}
+
+.brand-text {
+ font-size: 20px;
+ font-weight: 700;
+ letter-spacing: -0.5px;
+ white-space: nowrap;
+ overflow: hidden;
+ transition: opacity var(--transition-speed) var(--transition-easing), width var(--transition-speed) var(--transition-easing);
+}
+
+.collapsed .sidebar-brand {
+ display: none;
+}
+
+.sidebar-toggle {
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ padding: 8px;
+ border-radius: var(--radius-sm);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.2s, color 0.2s;
+ font-size: 20px;
+}
+
+.sidebar-toggle:hover {
+ background: var(--hover-bg);
+ color: var(--text-primary);
+}
+
+.sidebar-toggle i {
+ transition: transform 0.3s;
+}
+
+/* When collapsed, center the toggle button */
+.collapsed .sidebar-header {
+ padding: 20px 15px;
+}
+
+/* Ensure toggle button is always visible */
+.collapsed .sidebar-toggle {
+ flex-shrink: 0;
+ margin: 0 auto;
+}
+
+/* Sidebar Navigation */
+.sidebar-nav {
+ flex: 1;
+ padding: 20px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ overflow-y: auto;
+}
+
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+ border-radius: var(--radius-sm);
+ color: var(--text-secondary);
+ text-decoration: none;
+ transition: all 0.2s;
+ position: relative;
+ white-space: nowrap;
+}
+
+.nav-item i {
+ width: 20px;
+ height: 20px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 20px;
+}
+
+.nav-text {
+ transition: opacity var(--transition-speed) var(--transition-easing);
+}
+
+.collapsed .nav-text {
+ opacity: 0;
+ width: 0;
+ overflow: hidden;
+}
+
+.nav-item:hover {
+ background: var(--hover-bg);
+ color: var(--text-primary);
+}
+
+.nav-item.active {
+ background: var(--active-bg);
+ color: var(--accent-primary);
+}
+
+.nav-item.active::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 3px;
+ height: 24px;
+ background: var(--accent-primary);
+ border-radius: 0 3px 3px 0;
+}
+
+/* Collapsed sidebar tooltips - handled by Tippy.js */
+.collapsed .nav-item {
+ position: relative;
+}
+
+/* Custom Tippy.js theme for dashboard tooltips */
+.tippy-box[data-theme~='dark'] {
+ background: rgba(0, 0, 0, 0.8);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: var(--radius-sm);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ font-size: 14px;
+ backdrop-filter: blur(10px);
+}
+
+.tippy-box[data-theme~='dark'] .tippy-arrow {
+ color: rgba(0, 0, 0, 0.8);
+}
+
+.tippy-box[data-theme~='dark'] .tippy-content {
+ padding: 8px 12px;
+ color: white;
+ font-weight: 500;
+}
+
+/* Sidebar Footer */
+.sidebar-footer {
+ padding: 12px;
+ border-top: 1px solid var(--border-color);
+ margin-top: auto;
+}
+
+.profile-dropdown {
+ position: relative;
+}
+
+.profile-button {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px;
+ background: transparent;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.profile-button:hover {
+ background: var(--hover-bg);
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.profile-avatar {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+ font-size: 14px;
+ flex-shrink: 0;
+ overflow: hidden;
+ position: relative;
+}
+
+.profile-image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 50%;
+}
+
+.profile-initials {
+ color: white;
+ font-weight: 600;
+ font-size: 14px;
+}
+
+.profile-info {
+ flex: 1;
+ text-align: left;
+ overflow: hidden;
+ transition: opacity var(--transition-speed) var(--transition-easing);
+}
+
+.collapsed .profile-info {
+ opacity: 0;
+ width: 0;
+}
+
+.profile-name {
+ display: block;
+ font-size: 14px;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.profile-plan {
+ display: block;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.profile-chevron {
+ transition: transform var(--transition-speed) var(--transition-easing), opacity var(--transition-speed) var(--transition-easing);
+ color: var(--text-secondary);
+ font-size: 18px;
+}
+
+.collapsed .profile-chevron {
+ opacity: 0;
+}
+
+.profile-menu {
+ position: absolute;
+ bottom: 100%;
+ left: 0;
+ right: 0;
+ margin-bottom: 8px;
+ background: rgba(20, 25, 40, 0.98);
+ backdrop-filter: blur(20px);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: 8px;
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(10px);
+ transition: all 0.3s;
+ box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.4);
+}
+
+.profile-menu.active {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+}
+
+.collapsed .profile-menu {
+ left: 100%;
+ right: auto;
+ bottom: 0;
+ margin-left: 8px;
+ margin-bottom: 0;
+ width: 200px;
+}
+
+.menu-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ border-radius: var(--radius-sm);
+ color: var(--text-secondary);
+ text-decoration: none;
+ background: none;
+ border: none;
+ width: 100%;
+ text-align: left;
+ cursor: pointer;
+ transition: all 0.2s;
+ font-size: 14px;
+}
+
+.menu-item:hover {
+ background: var(--hover-bg);
+ color: var(--text-primary);
+}
+
+.menu-item[onclick*="logout"]:hover {
+ background: rgba(239, 68, 68, 0.1);
+ color: #ef4444;
+}
+
+.menu-item i {
+ width: 18px;
+ height: 18px;
+ flex-shrink: 0;
+ font-size: 18px;
+}
+
+.menu-divider {
+ height: 1px;
+ background: var(--border-color);
+ margin: 8px 0;
+}
+
+.profile-button.active .profile-chevron {
+ transform: rotate(180deg);
+}
+
+/* Main Content Area */
+.dashboard-main {
+ flex: 1;
+ margin-left: var(--sidebar-width);
+ transition: margin-left var(--transition-speed) var(--transition-easing);
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.collapsed~.dashboard-main {
+ margin-left: var(--sidebar-collapsed-width);
+}
+
+.dashboard-content {
+ flex: 1;
+ padding: 32px;
+ max-width: 1400px;
+ width: 100%;
+ margin: 0 auto;
+}
+
+/* Page Header */
+.page-header {
+ margin-bottom: 32px;
+}
+
+.page-title {
+ font-size: 32px;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin: 0 0 8px 0;
+}
+
+.page-subtitle {
+ font-size: 16px;
+ color: var(--text-secondary);
+ margin: 0;
+}
+
+/* Surface containers */
+.surface {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
+ backdrop-filter: blur(20px);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ padding: 24px;
+ margin-bottom: 24px;
+}
+
+/* Mobile Header */
+.mobile-header {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 60px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
+ backdrop-filter: blur(20px) saturate(1.2);
+ border-bottom: 1px solid var(--border-color);
+ z-index: 200;
+ padding: 0 16px;
+ align-items: center;
+ justify-content: space-between;
+ transition: opacity 0.3s ease, visibility 0.3s ease;
+}
+
+/* Hide mobile header when sidebar is open */
+.mobile-header.sidebar-open {
+ opacity: 0;
+ visibility: hidden;
+}
+
+.mobile-menu-toggle {
+ background: transparent;
+ border: none;
+ color: var(--text-primary);
+ cursor: pointer;
+ padding: 8px;
+ border-radius: var(--radius-sm);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 24px;
+ transition: background 0.2s, color 0.2s;
+}
+
+.mobile-menu-toggle:hover {
+ background: var(--hover-bg);
+}
+
+.mobile-brand {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ text-decoration: none;
+ color: var(--text-primary);
+ font-size: 18px;
+ font-weight: 700;
+ letter-spacing: -0.5px;
+}
+
+.mobile-brand img {
+ /* width: 28px; */
+ height: 26px;
+}
+
+/* Sidebar Overlay */
+.sidebar-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.6);
+ opacity: 0;
+ visibility: hidden;
+ transition: all 0.3s ease;
+ z-index: 150;
+ backdrop-filter: blur(4px);
+}
+
+.sidebar-overlay.active {
+ opacity: 1;
+ visibility: visible;
+}
+
+/* Responsive Design */
+@media (max-width: 768px) {
+
+ /* Show mobile header */
+ .mobile-header {
+ display: flex;
+ }
+
+ /* Hide desktop sidebar toggle in mobile */
+ .sidebar-toggle {
+ display: none;
+ }
+
+ /* Mobile sidebar positioning */
+ .dashboard-sidebar {
+ position: fixed;
+ left: 0;
+ top: 0;
+ height: 100vh;
+ transform: translateX(-100%);
+ transition: transform var(--transition-speed) var(--transition-easing);
+ z-index: 160;
+ /* Remove collapsed width behavior on mobile */
+ width: 280px !important;
+ }
+
+ /* Never show collapsed state on mobile */
+ .dashboard-sidebar.collapsed {
+ width: 280px !important;
+ transform: translateX(-100%);
+ }
+
+ /* Show sidebar when mobile-open */
+ .dashboard-sidebar.mobile-open {
+ transform: translateX(0);
+ }
+
+ /* Always show nav text on mobile */
+ .dashboard-sidebar .nav-text,
+ .dashboard-sidebar .brand-text,
+ .dashboard-sidebar .profile-info,
+ .dashboard-sidebar .profile-chevron {
+ opacity: 1 !important;
+ width: auto !important;
+ overflow: visible !important;
+ }
+
+ /* Always show sidebar brand on mobile */
+ .dashboard-sidebar .sidebar-brand {
+ display: flex !important;
+ }
+
+ /* Main content adjustments */
+ .dashboard-main {
+ margin-left: 0 !important;
+ /* Override collapsed state margin */
+ padding-top: 60px;
+ /* Account for mobile header */
+ }
+
+ /* Ensure collapsed sidebar doesn't affect main content on mobile */
+ .collapsed~.dashboard-main {
+ margin-left: 0 !important;
+ }
+
+ .dashboard-content {
+ padding: 20px 16px;
+ }
+
+ /* Page header adjustments */
+ .page-title {
+ font-size: 24px;
+ }
+
+ /* Profile menu positioning on mobile */
+ .profile-menu {
+ position: absolute;
+ bottom: 100%;
+ left: 0;
+ right: 0;
+ margin-bottom: 8px;
+ width: auto;
+ /* Ensure it appears above the button on mobile */
+ z-index: 1000;
+ }
+
+ /* Override collapsed positioning on mobile */
+ .collapsed .profile-menu {
+ position: absolute;
+ bottom: 100%;
+ left: 0;
+ right: 0;
+ margin-bottom: 8px;
+ margin-left: 0;
+ width: auto;
+ }
+}
+
+/* Loading states */
+.loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 48px;
+ color: var(--text-secondary);
+ font-size: 14px;
+}
+
+.loading::after {
+ content: '';
+ width: 20px;
+ height: 20px;
+ margin-left: 12px;
+ border: 2px solid var(--border-color);
+ border-top-color: var(--accent-primary);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
\ No newline at end of file
diff --git a/static/css/dashboard/dateRangePicker.css b/static/css/dashboard/dateRangePicker.css
new file mode 100644
index 00000000..46b59d71
--- /dev/null
+++ b/static/css/dashboard/dateRangePicker.css
@@ -0,0 +1,471 @@
+/* Date Range Picker Styles */
+.date-range-picker {
+ position: relative;
+ display: inline-block;
+}
+
+.date-range-trigger {
+ padding: 8px 14px;
+ border-radius: 8px;
+ color: rgba(255, 255, 255, 0.85);
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 160px;
+ transition: all 0.2s ease;
+ font-weight: 500;
+}
+
+.date-range-trigger:hover {
+ background: rgba(255, 255, 255, 0.15);
+ border-color: rgba(255, 255, 255, 0.2);
+ color: rgba(255, 255, 255, 0.95);
+}
+
+.date-range-trigger.active {
+ background: rgba(124, 58, 237, 0.15);
+ border-color: rgba(124, 58, 237, 0.4);
+ color: rgba(255, 255, 255, 0.95);
+}
+
+.selected-range {
+ flex: 1;
+ font-size: 14px;
+}
+
+.clock-icon {
+ font-size: 16px;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.dropdown-icon {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.6);
+ transition: transform 0.2s ease;
+}
+
+.date-range-trigger.active .dropdown-icon {
+ transform: rotate(180deg);
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.date-range-dropdown {
+ position: absolute;
+ top: calc(100% + 10px);
+ right: 0;
+ left: auto;
+ background: rgba(15, 20, 35, 0.95);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 12px;
+ box-shadow: rgba(0, 0, 0, 0.3) 0 20px 40px 0px;
+ backdrop-filter: blur(16px);
+ z-index: 1000;
+ min-width: 700px;
+ max-height: 500px;
+ overflow: hidden;
+ display: none;
+}
+
+.date-range-dropdown[style*="display: block"],
+.date-range-dropdown[style*="display:block"] {
+ display: block !important;
+ animation: slideDown 0.15s ease-out;
+}
+
+@keyframes slideDown {
+ from {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.dropdown-content {
+ position: relative;
+}
+
+.content-layout {
+ display: flex;
+ min-height: 400px;
+}
+
+.relative-section {
+ flex: 0.6;
+ border-right: 1px solid rgba(255, 255, 255, 0.1);
+ padding: 20px;
+}
+
+.custom-section {
+ flex: 1.4;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+}
+
+.section-title {
+ color: white;
+ margin: 0 0 15px 0;
+ font-size: 16px;
+ font-weight: 500;
+}
+
+/* Relative Section */
+.relative-options {
+ max-height: 380px;
+ overflow-y: auto;
+
+ /* Hide scrollbar */
+ scrollbar-width: none;
+ /* Firefox */
+ -ms-overflow-style: none;
+ /* IE and Edge */
+}
+
+.relative-options::-webkit-scrollbar {
+ display: none;
+ /* Chrome, Safari, Opera */
+}
+
+.relative-option {
+ padding: 12px 16px;
+ color: rgba(255, 255, 255, 0.8);
+ cursor: pointer;
+ border-radius: 6px;
+ margin-bottom: 2px;
+ transition: all 0.2s ease;
+ font-size: 14px;
+}
+
+.relative-option:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: white;
+}
+
+.relative-option.selected {
+ background: rgba(124, 58, 237, 0.3);
+ color: white;
+ border-left: 3px solid #7c3aed;
+}
+
+/* Custom Section */
+.custom-inputs {
+ margin-bottom: 20px;
+}
+
+.input-row {
+ display: flex;
+ gap: 15px;
+ margin-bottom: 15px;
+}
+
+.input-row .input-group {
+ flex: 1;
+ margin-bottom: 0;
+}
+
+.history-section {
+ flex: 1;
+ overflow-y: auto;
+}
+
+.input-group {
+ margin-bottom: 15px;
+}
+
+.input-group label {
+ display: block;
+ color: rgba(255, 255, 255, 0.8);
+ margin-bottom: 5px;
+ font-size: 12px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.input-group input {
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.1);
+ color: white;
+ font-size: 14px;
+ font-family: 'Courier New', monospace;
+ transition: all 0.2s ease;
+}
+
+.input-group input:focus {
+ outline: none;
+ border-color: #7c3aed;
+ background: rgba(255, 255, 255, 0.15);
+}
+
+.input-group input.invalid:focus {
+ border-color: #ef4444;
+ background: rgba(239, 68, 68, 0.1);
+}
+
+.input-group input::placeholder {
+ color: rgba(255, 255, 255, 0.4);
+ font-style: italic;
+}
+
+.apply-btn {
+ padding: 10px 20px;
+ background: #7c3aed;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 500;
+ transition: background-color 0.2s ease;
+ width: 100%;
+ margin-top: 10px;
+}
+
+.apply-btn:hover {
+ background: #6d28d9;
+}
+
+.apply-btn:disabled {
+ background: rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.5);
+ cursor: not-allowed;
+}
+
+.history-section h4 {
+ color: white;
+ margin: 0 0 15px 0;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.history-list {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ overflow-y: scroll;
+ max-height: 205px;
+}
+
+.history-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ color: rgba(255, 255, 255, 0.7);
+ cursor: pointer;
+ border-radius: 6px;
+ transition: all 0.2s ease;
+ font-size: 13px;
+}
+
+.history-item:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: white;
+}
+
+.history-icon {
+ font-size: 14px;
+ opacity: 0.7;
+}
+
+.history-empty {
+ color: rgba(255, 255, 255, 0.5);
+ text-align: center;
+ padding: 20px;
+ font-style: italic;
+ font-size: 13px;
+}
+
+/* Scrollbar Styling */
+.relative-options::-webkit-scrollbar,
+.history-section::-webkit-scrollbar {
+ width: 6px;
+}
+
+.relative-options::-webkit-scrollbar-track,
+.history-section::-webkit-scrollbar-track {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 3px;
+}
+
+.relative-options::-webkit-scrollbar-thumb,
+.history-section::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 3px;
+}
+
+.relative-options::-webkit-scrollbar-thumb:hover,
+.history-section::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+/* Animation removed - using transition instead */
+
+/* Responsive adjustments */
+@media (max-width: 768px) {
+
+ /* Mobile Bottom Sheet for Date Range Picker */
+ .date-range-dropdown {
+ position: fixed !important;
+ top: auto !important;
+ right: 0 !important;
+ left: 0 !important;
+ bottom: 0 !important;
+ width: 100% !important;
+ max-width: 100% !important;
+ min-width: unset !important;
+ max-height: 70vh;
+
+ background: linear-gradient(180deg, rgba(20, 25, 40, 0.95), rgba(15, 20, 35, 0.98));
+ backdrop-filter: blur(40px) saturate(150%);
+ -webkit-backdrop-filter: blur(40px) saturate(150%);
+ border-radius: 24px 24px 0 0;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-bottom: none;
+ box-shadow: 0 -10px 50px rgba(0, 0, 0, 0.5),
+ 0 -2px 20px rgba(124, 58, 237, 0.1);
+
+ /* Animation for smooth slide-up */
+ display: block !important;
+ visibility: hidden;
+ opacity: 0;
+ transform: translateY(100%);
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ visibility 0s linear 0.3s;
+ z-index: 10000 !important;
+
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+
+ /* Hide scrollbar */
+ scrollbar-width: none;
+ /* Firefox */
+ -ms-overflow-style: none;
+ /* IE and Edge */
+ }
+
+ /* Hide scrollbar for Chrome, Safari and Opera */
+ .date-range-dropdown::-webkit-scrollbar {
+ display: none;
+ }
+
+ .date-range-dropdown[style*="display: block"],
+ .date-range-dropdown[style*="display:block"] {
+ visibility: visible !important;
+ opacity: 1 !important;
+ transform: translateY(0) !important;
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ visibility 0s linear 0s;
+ }
+
+ /* Handle bar for bottom sheet */
+ .date-range-dropdown::before {
+ content: '';
+ position: sticky;
+ top: 12px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 36px;
+ height: 4px;
+ background: rgba(255, 255, 255, 0.3);
+ border-radius: 2px;
+ display: block;
+ margin: 0 auto 8px;
+ z-index: 10;
+ }
+
+ /* Backdrop overlay */
+ .date-range-dropdown::after {
+ content: '';
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(15, 20, 35, 0.95);
+ z-index: -1;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ pointer-events: none;
+ }
+
+ .date-range-dropdown[style*="display: block"]::after,
+ .date-range-dropdown[style*="display:block"]::after {
+ opacity: 1;
+ pointer-events: auto;
+ }
+
+ .content-layout {
+ flex-direction: column;
+ min-height: auto;
+ }
+
+ .relative-section {
+ flex: 1;
+ border-right: none;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ max-height: 300px;
+ overflow-y: auto;
+
+ /* Hide scrollbar in relative section */
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ }
+
+ .relative-section::-webkit-scrollbar {
+ display: none;
+ }
+
+ .custom-section {
+ flex: 1;
+ }
+
+ /* Hide only history section on mobile */
+ .history-section {
+ display: none !important;
+ }
+
+ .dropdown-header {
+ flex-direction: column;
+ gap: 12px;
+ padding: 20px 20px 16px;
+ }
+
+ .custom-inputs {
+ padding: 20px;
+ }
+}
+
+/* Dark theme consistency */
+.date-range-picker * {
+ box-sizing: border-box;
+}
+
+/* Focus states for accessibility */
+.tab-btn:focus,
+.relative-option:focus,
+.history-item:focus,
+.apply-btn:focus {
+ outline: 2px solid #7c3aed;
+ outline-offset: 2px;
+}
+
+/* Error states */
+.input-error {
+ color: #ef4444;
+ font-size: 12px;
+ margin-top: 5px;
+ display: block;
+}
\ No newline at end of file
diff --git a/static/css/dashboard/keys.css b/static/css/dashboard/keys.css
new file mode 100644
index 00000000..ca4d7c55
--- /dev/null
+++ b/static/css/dashboard/keys.css
@@ -0,0 +1,1107 @@
+/* API Keys Page Specific Styles */
+
+/* Page Header */
+.page-header-content {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 20px;
+}
+
+.page-header-text {
+ flex: 1;
+}
+
+.page-header-actions {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+}
+
+.page-header-actions .btn {
+ text-decoration: none;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 16px;
+}
+
+.page-header-actions .btn:hover {
+ background: rgba(255, 255, 255, 0.12);
+ border-color: rgba(255, 255, 255, 0.2);
+ text-decoration: none;
+}
+
+/* Keys Container */
+.keys-container {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
+ backdrop-filter: blur(20px);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+}
+
+/* Keys Toolbar */
+.keys-toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 20px 24px;
+ border-bottom: 1px solid var(--border-color);
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.toolbar-left h3 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.toolbar-right {
+ display: flex;
+ gap: 12px;
+}
+
+/* Button Styles */
+.btn {
+ padding: 10px 16px;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.btn:hover {
+ background: rgba(255, 255, 255, 0.12);
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.btn-primary {
+ background: var(--accent-primary);
+ border-color: var(--accent-primary);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: #6d31d8;
+ border-color: #6d31d8;
+}
+
+.btn-ghost {
+ background: transparent;
+ border-color: transparent;
+}
+
+.btn-ghost:hover {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.btn-danger {
+ background: transparent;
+ border-color: #ef4444;
+ color: #ef4444;
+}
+
+.btn-danger:hover:not(:disabled) {
+ background: rgba(239, 68, 68, 0.1);
+}
+
+.btn-sm {
+ padding: 6px 12px;
+ font-size: 12px;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn i {
+ font-size: 16px;
+}
+
+.btn-sm i {
+ font-size: 14px;
+}
+
+/* Keys List Container */
+.keys-list-container {
+ min-height: 400px;
+ position: relative;
+ background: transparent;
+}
+
+/* Table Layout */
+.keys-table {
+ overflow: hidden;
+}
+
+.table-header {
+ display: grid;
+ grid-template-columns: 200px 180px 1fr 120px 120px 100px 100px;
+ background: rgba(255, 255, 255, 0.08);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.header-cell {
+ padding: 16px 12px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ border-right: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.header-cell:last-child {
+ border-right: none;
+}
+
+.table-body {
+ background: rgba(15, 20, 40, 0.3);
+}
+
+.table-row {
+ display: grid;
+ grid-template-columns: 200px 180px 1fr 120px 120px 100px 100px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+ transition: background 0.2s;
+}
+
+.table-row:hover {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.table-row:last-child {
+ border-bottom: none;
+}
+
+.table-cell {
+ padding: 16px 12px;
+ display: flex;
+ align-items: center;
+ border-right: 1px solid rgba(255, 255, 255, 0.03);
+ min-height: 60px;
+}
+
+.table-cell:last-child {
+ border-right: none;
+}
+
+/* Column Specific Styles */
+.key-name-info {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ width: 100%;
+}
+
+.key-name {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.key-description {
+ font-size: 12px;
+ color: var(--text-secondary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.key-prefix {
+ font-family: 'Monaco', 'Courier New', monospace;
+ font-size: 13px;
+ color: var(--text-secondary);
+ background: rgba(255, 255, 255, 0.05);
+ padding: 4px 8px;
+ border-radius: 4px;
+ border: 1px solid var(--border-color);
+}
+
+.scopes-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.scope-tag {
+ display: inline-block;
+ padding: 2px 6px;
+ background: rgba(124, 58, 237, 0.1);
+ color: var(--accent-primary);
+ border-radius: 4px;
+ font-size: 12px;
+ font-family: 'Monaco', 'Courier New', monospace;
+ border: 1px solid rgba(124, 58, 237, 0.2);
+}
+
+.col-created,
+.col-expires {
+ font-size: 13px;
+ color: var(--text-primary);
+}
+
+.status-badge {
+ display: inline-block;
+ padding: 4px 10px;
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.status-active {
+ background: rgba(34, 197, 94, 0.1);
+ color: #22c55e;
+ border: 1px solid rgba(34, 197, 94, 0.2);
+}
+
+.status-revoked {
+ background: rgba(239, 68, 68, 0.1);
+ color: #ef4444;
+ border: 1px solid rgba(239, 68, 68, 0.2);
+}
+
+.status-expired {
+ background: rgba(245, 158, 11, 0.1);
+ color: #f59e0b;
+ border: 1px solid rgba(245, 158, 11, 0.2);
+}
+
+/* Modal Styles */
+.modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10000;
+ padding: 20px;
+}
+
+.modal-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(10px) saturate(180%) brightness(0.7);
+}
+
+.modal-content {
+ position: relative;
+ max-width: 500px;
+ width: 100%;
+ max-height: 90vh;
+ overflow-y: auto;
+ background: rgba(15, 20, 35, 0.5);
+ backdrop-filter: blur(60px);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
+ animation: modalSlideIn 0.3s ease;
+}
+
+@keyframes modalSlideIn {
+ from {
+ opacity: 0;
+ transform: scale(0.95) translateY(-20px);
+ }
+
+ to {
+ opacity: 1;
+ transform: scale(1) translateY(0);
+ }
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 24px 20px;
+ margin-bottom: 20px;
+ border-bottom: 1px solid var(--border-color);
+ gap: 15px;
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.modal-header h2 {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.modal-close {
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ padding: 8px;
+ border-radius: var(--radius-sm);
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.modal-close:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--text-primary);
+}
+
+.modal-close i {
+ font-size: 20px;
+}
+
+.modal-body {
+ padding: 0 24px;
+}
+
+.modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ padding: 20px 24px 24px 24px;
+ border-top: 1px solid var(--border-color);
+ background: rgba(255, 255, 255, 0.02);
+ margin-top: 24px;
+}
+
+/* Form Styles */
+.form-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.field label {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.field input[type="text"],
+.field input[type="datetime-local"],
+.field textarea {
+ padding: 12px 16px;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-size: 14px;
+ font-family: inherit;
+ transition: all 0.2s;
+ resize: vertical;
+}
+
+.field input:focus,
+.field textarea:focus {
+ outline: none;
+ border-color: var(--accent-primary);
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.field input::placeholder,
+.field textarea::placeholder {
+ color: rgba(255, 255, 255, 0.4);
+}
+
+/* Two-Tier Permissions Selector */
+.permissions-container {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+/* Access Level Selector */
+.access-level-selector {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+.access-option {
+ position: relative;
+}
+
+.access-option input[type="radio"] {
+ display: none;
+}
+
+.access-label {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ position: relative;
+ overflow: hidden;
+}
+
+.access-label::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(135deg, rgba(124, 58, 237, 0.05), rgba(79, 70, 229, 0.05));
+ opacity: 0;
+ transition: opacity 0.3s ease;
+}
+
+.access-label:hover {
+ background: rgba(255, 255, 255, 0.06);
+ border-color: rgba(124, 58, 237, 0.3);
+ transform: translateY(-1px);
+}
+
+.access-label:hover::before {
+ opacity: 1;
+}
+
+.access-option input:checked+.access-label {
+ background: rgba(124, 58, 237, 0.1);
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.2);
+}
+
+.access-option input:checked+.access-label::before {
+ opacity: 1;
+}
+
+.access-icon {
+ font-size: 18px;
+ flex-shrink: 0;
+ position: relative;
+ z-index: 1;
+ color: var(--accent-primary);
+}
+
+.access-text {
+ flex: 1;
+ position: relative;
+ z-index: 1;
+}
+
+.access-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 2px;
+}
+
+.access-subtitle {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+/* Detailed Permissions */
+.detailed-permissions {
+ overflow: hidden;
+ transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
+ opacity: 1;
+ max-height: 500px;
+}
+
+.detailed-permissions.hidden {
+ opacity: 0;
+ max-height: 0;
+ margin-top: 0;
+ pointer-events: none;
+}
+
+.permissions-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ animation: fadeInUp 0.5s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+@keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.permission-item {
+ cursor: pointer;
+}
+
+.permission-item input {
+ display: none;
+}
+
+.permission-content {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 2px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+ position: relative;
+ overflow: hidden;
+ min-height: 60px;
+}
+
+.permission-content::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(135deg, rgba(124, 58, 237, 0.08), rgba(79, 70, 229, 0.08));
+ opacity: 0;
+ transition: opacity 0.3s ease;
+}
+
+.permission-item:hover .permission-content {
+ background: rgba(255, 255, 255, 0.06);
+ border-color: rgba(124, 58, 237, 0.4);
+ transform: translateY(-1px);
+}
+
+.permission-item:hover .permission-content::before {
+ opacity: 1;
+}
+
+.permission-item input:checked+.permission-content {
+ background: rgba(124, 58, 237, 0.15);
+ border-color: var(--accent-primary);
+ box-shadow: 0 2px 8px rgba(124, 58, 237, 0.2);
+}
+
+.permission-item input:checked+.permission-content::before {
+ opacity: 1;
+}
+
+.permission-icon {
+ font-size: 20px;
+ color: var(--accent-primary);
+ position: relative;
+ z-index: 1;
+ flex-shrink: 0;
+ width: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.permission-details {
+ flex: 1;
+ position: relative;
+ z-index: 1;
+}
+
+.permission-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 2px;
+}
+
+.permission-description {
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.4;
+}
+
+/* Scopes */
+.scopes {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.scope {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: all 0.2s;
+ font-size: 14px;
+ color: var(--text-primary);
+}
+
+.scope:hover {
+ background: rgba(255, 255, 255, 0.06);
+ border-color: rgba(255, 255, 255, 0.15);
+}
+
+.scope input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--accent-primary);
+}
+
+.scope:has(input:checked) {
+ background: rgba(124, 58, 237, 0.1);
+ border-color: rgba(124, 58, 237, 0.3);
+}
+
+/* Success Modal Styles */
+.key-success-modal .success-content {
+ text-align: center;
+ padding: 8px 0;
+}
+
+.key-success-modal .modal-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ padding: 20px 21px 16px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.key-success-modal .modal-title-section-success {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.key-success-modal .modal-icon {
+ width: 48px;
+ height: 48px;
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 24px;
+ margin: auto;
+ flex-shrink: 0;
+}
+
+.key-success-modal .success-icon {
+ background: rgba(34, 197, 94, 0.1);
+ color: #22c55e;
+ border: 2px solid rgba(34, 197, 94, 0.2);
+}
+
+.key-success-modal .modal-title {
+ font-size: 24px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 0 0 4px 0;
+ line-height: 1.2;
+}
+
+.key-success-modal .modal-subtitle {
+ font-size: 14px;
+ color: var(--text-secondary);
+ margin: 0;
+ line-height: 1.4;
+}
+
+.key-success-modal .modal-close {
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 24px;
+ cursor: pointer;
+ padding: 8px;
+ border-radius: 8px;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+}
+
+.key-success-modal .modal-close:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: var(--text-primary);
+}
+
+.key-success-modal .modal-body {
+ padding: 28px 32px 32px;
+}
+
+.token-display {
+ display: flex;
+ align-items: stretch;
+ gap: 0;
+ margin-bottom: 24px;
+}
+
+.token-input {
+ flex: 1;
+ padding: 12px 16px;
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid var(--border-color);
+ border-top-left-radius: var(--radius-sm);
+ border-bottom-left-radius: var(--radius-sm);
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ color: var(--text-primary);
+ font-family: 'Monaco', 'Courier New', monospace;
+ font-size: 13px;
+ transition: all 0.2s;
+}
+
+.token-input:hover {
+ background: rgba(0, 0, 0, 0.4);
+ border-color: var(--accent-primary);
+}
+
+.token-input:focus {
+ border-color: var(--accent-primary);
+}
+
+.copy-btn {
+ padding: 12px 16px;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--border-color);
+ border-left: none;
+ border-top-right-radius: var(--radius-sm);
+ border-bottom-right-radius: var(--radius-sm);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 44px;
+}
+
+.copy-btn:hover {
+ background: rgba(255, 255, 255, 0.12);
+ color: var(--text-primary);
+ border-color: var(--accent-primary);
+}
+
+.copy-btn i {
+ font-size: 16px;
+}
+
+.security-warnings {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 16px;
+ background: rgba(245, 158, 11, 0.05);
+ border: 1px solid rgba(245, 158, 11, 0.1);
+ border-radius: var(--radius-sm);
+}
+
+.warning-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: #f59e0b;
+ font-size: 13px;
+}
+
+.warning-item i {
+ font-size: 16px;
+ flex-shrink: 0;
+}
+
+/* Loading and Empty States */
+.loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 48px;
+ color: var(--text-secondary);
+ font-size: 14px;
+}
+
+.loading::after {
+ content: '';
+ width: 20px;
+ height: 20px;
+ margin-left: 12px;
+ border: 2px solid var(--border-color);
+ border-top-color: var(--accent-primary);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+.empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 300px;
+ color: var(--text-secondary);
+ font-size: 16px;
+}
+
+/* Animations */
+.fade-in {
+ animation: fadeIn 0.3s ease;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Responsive Design */
+@media (max-width: 1200px) {
+
+ .table-header,
+ .table-row {
+ grid-template-columns: 180px 160px 1fr 100px 100px 80px 80px;
+ }
+}
+
+@media (max-width: 992px) {
+
+ .table-header,
+ .table-row {
+ grid-template-columns: 150px 140px 1fr 90px 90px 70px 70px;
+ }
+
+ .header-cell,
+ .table-cell {
+ padding: 12px 8px;
+ font-size: 12px;
+ }
+}
+
+@media (max-width: 768px) {
+ .dashboard-content {
+ padding: 0;
+ }
+
+ .page-header {
+ padding: 20px;
+ margin-bottom: 10px;
+ }
+
+ .page-header-content {
+ flex-direction: column;
+ gap: 0;
+ }
+
+ .page-header-actions {
+ justify-content: right;
+ width: 100%;
+ margin-top: 10px;
+ }
+
+ .keys-toolbar {
+ gap: 16px;
+ }
+
+ .toolbar-right {
+ justify-content: center;
+ }
+
+ /* Fix the container width to viewport */
+ .keys-container {
+ overflow: visible;
+ /* Allow dropdown to overflow */
+ max-width: 100vw;
+ /* Never exceed viewport width */
+ width: 100%;
+ /* Take full available width */
+ padding: 0;
+ border-radius: 0;
+ margin: 0;
+ }
+
+ .keys-toolbar {
+ overflow: visible;
+ /* Allow any dropdowns to overflow */
+ width: 100%;
+ padding: 20px 16px;
+ gap: 12px;
+ }
+
+ /* ONLY the keys-list-container should scroll horizontally */
+ .keys-list-container {
+ overflow-x: auto;
+ overflow-y: visible;
+ -webkit-overflow-scrolling: touch;
+ /* Smooth scrolling on iOS */
+ padding: 0;
+ /* Remove any padding that might interfere */
+ width: 100%;
+ /* Constrain to parent width */
+ max-width: 100%;
+ /* Never exceed parent */
+ }
+
+ /* Style scrollbar */
+ .keys-list-container::-webkit-scrollbar {
+ height: 6px;
+ }
+
+ .keys-list-container::-webkit-scrollbar-track {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 3px;
+ }
+
+ .keys-list-container::-webkit-scrollbar-thumb {
+ background: rgba(124, 58, 237, 0.6);
+ border-radius: 3px;
+ }
+
+ .keys-list-container::-webkit-scrollbar-thumb:hover {
+ background: rgba(124, 58, 237, 0.8);
+ }
+
+ /* Table stays in table format, just scrollable */
+ .keys-table {
+ min-width: 1100px;
+ /* Ensure table doesn't shrink below readable width */
+ width: max-content;
+ /* Let table be as wide as it needs */
+ }
+
+ /* Fix column widths - no minmax, just fixed widths */
+ .table-header,
+ .table-row {
+ grid-template-columns: 200px 180px 220px 130px 130px 100px 100px;
+ width: 100%;
+ }
+
+ /* Adjust font sizes for mobile */
+ .header-cell {
+ font-size: 11px;
+ padding: 12px 10px;
+ white-space: nowrap;
+ }
+
+ .table-cell {
+ padding: 14px 10px;
+ font-size: 13px;
+ }
+
+ .key-name {
+ font-size: 13px;
+ font-weight: 600;
+ }
+
+ .key-description {
+ font-size: 11px;
+ }
+
+ .key-prefix {
+ font-size: 12px;
+ }
+
+ .scope-tag {
+ font-size: 11px;
+ }
+
+ /* Make date columns compact */
+ .col-created,
+ .col-expires {
+ font-size: 12px;
+ }
+
+ /* Slightly smaller status badges */
+ .status-badge {
+ font-size: 10px;
+ padding: 3px 8px;
+ }
+
+ /* Empty state adjustments */
+ .empty {
+ min-width: 100%;
+ /* Prevent empty state from being cut off */
+ }
+
+ .modal-content {
+ max-width: 95vw;
+ margin: 20px;
+ }
+
+ /* Responsive permissions selector */
+ .access-level-selector {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ }
+
+ .access-label {
+ padding: 12px;
+ }
+
+ .access-title {
+ font-size: 13px;
+ }
+
+ .access-subtitle {
+ font-size: 11px;
+ }
+
+ .permission-content {
+ padding: 10px 12px;
+ min-height: 50px;
+ }
+
+ .permission-icon {
+ font-size: 18px;
+ }
+
+ .permission-title {
+ font-size: 13px;
+ }
+
+ .permission-description {
+ font-size: 11px;
+ }
+}
\ No newline at end of file
diff --git a/static/css/dashboard/links.css b/static/css/dashboard/links.css
new file mode 100644
index 00000000..121d976a
--- /dev/null
+++ b/static/css/dashboard/links.css
@@ -0,0 +1,1220 @@
+/* Links Page Specific Styles */
+
+/* Page Header */
+.page-header-content {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 20px;
+}
+
+.page-header-text {
+ flex: 1;
+}
+
+.page-header-actions {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+}
+
+.page-header-actions .btn {
+ text-decoration: none;
+ background: var(--accent-primary);
+ border: 1px solid var(--accent-primary);
+ border-radius: var(--radius-sm);
+ color: white;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 16px;
+}
+
+.page-header-actions .btn:hover {
+ background: #6d31d8;
+ border-color: #6d31d8;
+ text-decoration: none;
+}
+
+/* Success Modal Close Button */
+#link-success-modal .modal-close {
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 24px;
+ cursor: pointer;
+ padding: 8px;
+ border-radius: 8px;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+}
+
+#link-success-modal .modal-close:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: var(--text-primary);
+}
+
+/* Success Modal Styles */
+.success-content {
+ text-align: center;
+ padding: 8px 0;
+}
+
+/* Specific spacing for success modal body */
+#link-success-modal .modal-body {
+ padding: 28px 32px 32px;
+}
+
+.link-display {
+ margin-bottom: 28px;
+}
+
+.link-display .field {
+ margin-bottom: 8px;
+}
+
+.link-display label {
+ color: var(--text-secondary);
+ font-size: 14px;
+ font-weight: 500;
+ margin-bottom: 8px;
+ display: block;
+}
+
+.link-result {
+ display: flex;
+ align-items: stretch;
+ gap: 0;
+ margin-top: 12px;
+}
+
+.link-input {
+ flex: 1;
+ padding: 12px 16px;
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid var(--border-color);
+ border-top-left-radius: var(--radius-sm);
+ border-bottom-left-radius: var(--radius-sm);
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ color: var(--text-primary);
+ font-family: 'Monaco', 'Courier New', monospace;
+ font-size: 13px;
+ transition: all 0.2s;
+}
+
+.link-input:hover {
+ background: rgba(0, 0, 0, 0.4);
+ border-color: var(--accent-primary);
+}
+
+.link-actions {
+ display: flex;
+ justify-content: center;
+ gap: 16px;
+ margin-top: 20px;
+ margin-bottom: 0;
+ padding-top: 16px;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+/* Action buttons styling */
+.link-actions .btn {
+ min-width: 120px;
+ padding: 12px 20px;
+ font-size: 14px;
+ font-weight: 500;
+ border-radius: 8px;
+ transition: all 0.2s ease;
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ color: var(--text-primary);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.link-actions .btn:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ background: rgba(255, 255, 255, 0.12);
+ border-color: rgba(255, 255, 255, 0.25);
+}
+
+.link-actions .btn:active {
+ transform: translateY(0);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+}
+
+.link-actions .btn i {
+ font-size: 16px;
+ transition: transform 0.2s ease;
+}
+
+.link-actions .btn:hover i {
+ transform: scale(1.1);
+}
+
+/* QR block inside success modal */
+.qr-block {
+ margin: 20px auto 20px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+}
+
+.qr-container {
+ position: relative;
+ display: inline-block;
+ border-radius: 12px;
+ overflow: hidden;
+ cursor: pointer;
+ transition: transform 0.2s ease;
+}
+
+.qr-container:hover {
+ transform: scale(1.02);
+}
+
+.qr-image {
+ width: 150px;
+ height: 150px;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid var(--border-color);
+ padding: 8px;
+ display: block;
+ transition: opacity 0.2s ease;
+}
+
+/* QR loading animation */
+.qr-image:not([src*="qr.spoo.me"]) {
+ animation: qr-loading 1.5s infinite;
+}
+
+@keyframes qr-loading {
+ 0% {
+ background: rgba(255, 255, 255, 0.04);
+ }
+
+ 50% {
+ background: rgba(255, 255, 255, 0.08);
+ }
+
+ 100% {
+ background: rgba(255, 255, 255, 0.04);
+ }
+}
+
+/* QR overlay */
+.qr-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(4px);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ opacity: 0;
+ visibility: hidden;
+ transition: all 0.3s ease;
+ border-radius: 12px;
+ color: white;
+ font-size: 14px;
+ font-weight: 500;
+ letter-spacing: 0.5px;
+}
+
+.qr-overlay i {
+ font-size: 24px;
+ margin-bottom: 4px;
+}
+
+.qr-container:hover .qr-overlay {
+ opacity: 1;
+ visibility: visible;
+}
+
+/* Remove old download button styles */
+.qr-block .btn {
+ display: none;
+}
+
+.link-result .copy-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 14px;
+ height: 44px;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid var(--border-color);
+ border-top-right-radius: var(--radius-sm);
+ border-bottom-right-radius: var(--radius-sm);
+ border-left: 0;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.link-result .copy-btn:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: var(--text-primary);
+ border-color: var(--accent-primary);
+}
+
+.link-result .copy-btn i {
+ font-size: 18px;
+}
+
+.link-result input {
+ border-bottom-right-radius: 0 !important;
+ border-top-right-radius: 0 !important;
+}
+
+.copy-btn.copied,
+.btn.copied {
+ transition: all 0.2s ease;
+}
+
+.modal-icon {
+ width: 60px;
+ height: 60px;
+ margin: 0 auto 16px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 2px solid rgba(34, 197, 94, 0.2);
+}
+
+.success-icon {
+ background: rgba(34, 197, 94, 0.1);
+ color: #22c55e;
+}
+
+.success-icon i {
+ font-size: 28px;
+}
+
+/* Form Error States */
+.field input.error,
+.field textarea.error {
+ border-color: #ef4444;
+ background: rgba(239, 68, 68, 0.05);
+}
+
+.field-error {
+ color: #ef4444;
+ font-size: 12px;
+ margin-top: 4px;
+ display: block;
+}
+
+/* Loading Animation */
+.spinning {
+ animation: spin 1s linear infinite;
+}
+
+/* Button Loading State */
+.btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.btn:disabled:hover {
+ background: inherit;
+ border-color: inherit;
+}
+
+/* Links Container */
+.links-container {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
+ backdrop-filter: blur(20px);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+}
+
+/* Toolbar */
+.toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 20px;
+ flex-wrap: wrap;
+ position: relative;
+ z-index: 100;
+ padding: 20px 24px;
+ border-bottom: 1px solid var(--border-color);
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.filters {
+ flex: 1;
+ max-width: 400px;
+}
+
+.search-input-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.search-icon {
+ position: absolute;
+ left: 12px;
+ color: var(--text-secondary);
+ font-size: 16px;
+ pointer-events: none;
+ z-index: 1;
+}
+
+.field.search input {
+ width: 100%;
+ padding: 10px 16px 10px 40px;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-size: 14px;
+ transition: all 0.2s;
+}
+
+.field.search input:focus {
+ outline: none;
+ border-color: var(--accent-primary);
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.field.search input:focus+.search-icon,
+.search-input-wrapper:hover .search-icon {
+ color: var(--accent-primary);
+}
+
+.field.search input::placeholder {
+ color: var(--text-secondary);
+}
+
+/* Options Button */
+.btn {
+ padding: 10px 16px;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.btn:hover {
+ background: rgba(255, 255, 255, 0.12);
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.btn-primary {
+ background: var(--accent-primary);
+ border-color: var(--accent-primary);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: #6d31d8;
+ border-color: #6d31d8;
+}
+
+.btn-ghost {
+ background: transparent;
+ border-color: transparent;
+}
+
+.btn-ghost:hover {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.caret {
+ font-size: 10px;
+ opacity: 0.7;
+}
+
+/* Options Dropdown */
+.options {
+ position: relative;
+ z-index: 101;
+}
+
+.options-dropdown {
+ position: absolute;
+ top: calc(100% + 10px);
+ right: 0;
+ left: auto;
+ width: 500px;
+ max-width: 90vw;
+ padding: 20px;
+ background: rgba(15, 20, 35, 0.98);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 12px;
+ backdrop-filter: blur(60px);
+ box-shadow: rgba(0, 0, 0, 0.3) 0 20px 40px 0px;
+ z-index: 102;
+ visibility: hidden;
+ opacity: 0;
+ transform: translateY(-8px);
+ transition: opacity 0.15s ease-out, transform 0.15s ease-out, visibility 0s linear 0.15s;
+}
+
+.options-dropdown.show {
+ visibility: visible;
+ opacity: 1;
+ transform: translateY(0);
+ transition: opacity 0.15s ease-out, transform 0.15s ease-out, visibility 0s linear 0s;
+}
+
+.options-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 16px;
+ margin-bottom: 20px;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.field label {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+/* Select and DateTime Wrappers */
+.select-wrapper,
+.datetime-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.select-wrapper select,
+.datetime-wrapper input,
+.field select,
+.field input[type="datetime-local"] {
+ padding: 8px 12px;
+ padding-right: 36px;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-size: 14px;
+ transition: all 0.2s;
+ width: 100%;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+}
+
+.select-wrapper select:focus,
+.datetime-wrapper input:focus,
+.field select:focus,
+.field input[type="datetime-local"]:focus {
+ outline: none;
+ border-color: var(--accent-primary);
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.select-icon {
+ position: absolute;
+ right: 12px;
+ color: var(--text-secondary);
+ font-size: 16px;
+ pointer-events: none;
+ transition: color 0.2s;
+}
+
+.datetime-icon {
+ position: absolute;
+ right: 12px;
+ color: var(--text-secondary);
+ font-size: 16px;
+ pointer-events: auto;
+ cursor: pointer;
+ transition: color 0.2s;
+}
+
+.select-wrapper:hover .select-icon,
+.datetime-wrapper:hover .datetime-icon,
+.select-wrapper select:focus+.select-icon,
+.datetime-wrapper input:focus+.datetime-icon {
+ color: var(--accent-primary);
+}
+
+/* Hide default datetime-local calendar icon */
+input[type="datetime-local"]::-webkit-calendar-picker-indicator {
+ display: none;
+}
+
+input[type="datetime-local"]::-webkit-inner-spin-button {
+ display: none;
+}
+
+/* Segmented Control */
+.seg {
+ display: flex;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+ position: relative;
+}
+
+.seg button {
+ flex: 1;
+ padding: 8px;
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 13px;
+ cursor: pointer;
+ transition: color 0.2s;
+ position: relative;
+ z-index: 1;
+}
+
+.seg button:hover {
+ color: var(--text-primary);
+}
+
+.seg button.active {
+ color: var(--text-primary);
+}
+
+.seg-indicator {
+ position: absolute;
+ top: 3px;
+ bottom: 2.5px;
+ left: 2.5px;
+ width: calc(33.33% - 2.67px);
+ background: rgba(124, 58, 237, 0.3);
+ border-radius: calc(var(--radius-sm) - 2px);
+ transition: transform 0.3s ease;
+ pointer-events: none;
+}
+
+.seg--2 .seg-indicator {
+ width: calc(50% - 2.31px);
+}
+
+.seg--3 .seg-indicator {
+ width: calc(33.33% - 2.11px);
+}
+
+/* Segmented control state management */
+.seg[data-active="0"] .seg-indicator {
+ transform: translateX(0);
+}
+
+.seg[data-active="1"] .seg-indicator {
+ transform: translateX(calc(100% + 0.67px));
+}
+
+.seg[data-active="2"] .seg-indicator {
+ transform: translateX(calc(200% + 1.33px));
+}
+
+.seg--2[data-active="0"] .seg-indicator {
+ transform: translateX(0);
+}
+
+.seg--2[data-active="1"] .seg-indicator {
+ transform: translateX(100%);
+}
+
+.options-actions {
+ display: flex;
+ gap: 12px;
+ justify-content: flex-end;
+ padding-top: 16px;
+ border-top: 1px solid var(--border-color);
+}
+
+/* List Container */
+.list-container {
+ min-height: 400px;
+ position: relative;
+ z-index: 1;
+ background: transparent;
+}
+
+/* Table Layout */
+.links-table {
+ overflow: hidden;
+}
+
+.table-header {
+ display: grid;
+ grid-template-columns: 200px minmax(0, 1fr) 140px 140px 80px 120px;
+ background: rgba(255, 255, 255, 0.08);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.header-cell {
+ padding: 16px 12px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ border-right: 1px solid rgba(255, 255, 255, 0.05);
+ overflow: hidden;
+}
+
+.header-cell:last-child {
+ border-right: none;
+}
+
+.table-body {
+ background: rgba(15, 20, 40, 0.3);
+}
+
+.table-row {
+ display: grid;
+ grid-template-columns: 200px minmax(0, 1fr) 140px 140px 80px 120px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+ transition: background 0.2s;
+}
+
+.table-row:hover {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.table-row:last-child {
+ border-bottom: none;
+}
+
+.table-cell {
+ padding: 16px 12px;
+ display: flex;
+ align-items: center;
+ border-right: 1px solid rgba(255, 255, 255, 0.03);
+ min-height: 60px;
+ min-width: 0;
+ /* Allow shrinking */
+ overflow: hidden;
+}
+
+.table-cell:last-child {
+ border-right: none;
+}
+
+/* Column specific styles */
+.col-short-url .link-short {
+ color: var(--accent-primary);
+ font-weight: 600;
+ font-size: 14px;
+ text-decoration: none;
+ transition: color 0.2s;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.col-short-url .link-short:hover {
+ color: #6d31d8;
+}
+
+.col-long-url {
+ min-width: 0;
+ /* Important for flex items to shrink */
+ overflow: hidden;
+}
+
+.col-long-url .link-long {
+ color: var(--text-secondary);
+ font-size: 13px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ width: 100%;
+ max-width: 100%;
+ display: block;
+}
+
+.col-created,
+.col-last-click {
+ font-size: 13px;
+ color: var(--text-primary);
+}
+
+.col-clicks {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+ justify-content: center;
+}
+
+/* Attribute Badges */
+.attribute-badges {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 6px;
+ font-size: 12px;
+ transition: all 0.2s;
+}
+
+.badge i {
+ font-size: 14px;
+}
+
+.badge-status.badge-active {
+ background: rgba(34, 197, 94, 0.1);
+ color: #22c55e;
+ border: 1px solid rgba(34, 197, 94, 0.2);
+}
+
+.badge-status.badge-inactive {
+ background: rgba(239, 68, 68, 0.1);
+ color: #ef4444;
+ border: 1px solid rgba(239, 68, 68, 0.2);
+}
+
+.badge-password {
+ background: rgba(245, 158, 11, 0.1);
+ color: #f59e0b;
+ border: 1px solid rgba(245, 158, 11, 0.2);
+}
+
+.badge-max-clicks {
+ background: rgba(37, 99, 235, 0.1);
+ color: #2563eb;
+ border: 1px solid rgba(37, 99, 235, 0.2);
+}
+
+.badge-private {
+ background: rgba(239, 68, 68, 0.1);
+ color: #ef4444;
+ border: 1px solid rgba(239, 68, 68, 0.2);
+}
+
+.badge-block-bots {
+ background: rgba(16, 185, 129, 0.1);
+ color: #10b981;
+ border: 1px solid rgba(16, 185, 129, 0.2);
+}
+
+.badge:hover {
+ transform: scale(1.1);
+}
+
+/* Empty State */
+.empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 400px;
+ padding: 40px 20px;
+}
+
+.empty-state {
+ text-align: center;
+}
+
+.empty-icon {
+ width: 80px;
+ height: 80px;
+ margin: 0 auto 24px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, rgba(124, 58, 237, 0.1), rgba(37, 99, 235, 0.1));
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--accent-primary);
+}
+
+.empty-icon i {
+ font-size: 36px;
+}
+
+.empty-title {
+ font-size: 24px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 0 0 12px 0;
+}
+
+.empty-message {
+ font-size: 16px;
+ color: var(--text-secondary);
+ line-height: 1.6;
+ margin: 0;
+}
+
+/* Pagination */
+.pagination-bar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ padding: 16px 24px;
+ border-top: 1px solid var(--border-color);
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.pagination-controls {
+ display: flex;
+ gap: 8px;
+}
+
+.pagination-controls button {
+ padding: 8px 16px;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ color: var(--text-secondary);
+ font-size: 14px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.pagination-controls button:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--text-primary);
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.pagination-controls button:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.pagination-info {
+ color: var(--text-secondary);
+ font-size: 14px;
+}
+
+/* Animations */
+.fade-in {
+ animation: fadeIn 0.3s ease;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Responsive */
+@media (max-width: 1200px) {
+
+ .table-header,
+ .table-row {
+ grid-template-columns: 180px 1fr 120px 120px 70px 100px;
+ }
+}
+
+@media (max-width: 992px) {
+
+ .table-header,
+ .table-row {
+ grid-template-columns: 160px 1fr 100px 100px 60px 80px;
+ }
+
+ .header-cell,
+ .table-cell {
+ padding: 12px 8px;
+ font-size: 12px;
+ }
+}
+
+@media (max-width: 768px) {
+ .dashboard-content {
+ padding: 0 !important;
+ }
+
+ .page-header {
+ padding: 20px;
+ margin-bottom: 10px;
+ }
+
+ .page-header-content {
+ flex-direction: column;
+ gap: 0;
+ }
+
+ .page-header-actions {
+ justify-content: right;
+ width: 100%;
+ margin-top: 10px;
+ }
+
+ .filters {
+ max-width: none;
+ }
+
+ /* Mobile Bottom Sheet for Dropdowns */
+ .options-dropdown {
+ /* Reset desktop positioning */
+ position: fixed !important;
+ top: auto !important;
+ right: 0 !important;
+ left: 0 !important;
+ bottom: 0 !important;
+ width: 100% !important;
+ max-width: 100% !important;
+ max-height: 85vh;
+ margin: 0 !important;
+
+ /* Mobile bottom sheet styling */
+ background: rgba(15, 20, 35, 0.98);
+ backdrop-filter: blur(20px);
+ border-radius: 24px 24px 0 0;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-bottom: none;
+ box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.4);
+
+ /* Animation - use visibility and opacity for smooth transitions */
+ display: block !important;
+ visibility: hidden;
+ opacity: 0;
+ transform: translateY(100%);
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ visibility 0s linear 0.3s;
+ z-index: 10000 !important;
+
+ /* Enable scrolling for long content */
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ .options-dropdown[style*="display: block"],
+ .options-dropdown[style*="display:block"] {
+ visibility: visible !important;
+ opacity: 1 !important;
+ transform: translateY(0) !important;
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ visibility 0s linear 0s;
+ }
+
+ /* Backdrop overlay for mobile bottom sheet */
+ .options-dropdown::before {
+ content: '';
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(15, 20, 35, 0.95);
+ z-index: -1;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ pointer-events: none;
+ }
+
+ .options-dropdown[style*="display: block"]::before,
+ .options-dropdown[style*="display:block"]::before {
+ opacity: 1;
+ pointer-events: auto;
+ }
+
+ /* Add handle bar at top of bottom sheet */
+ .options-dropdown::after {
+ content: '';
+ position: absolute;
+ top: 12px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 36px;
+ height: 4px;
+ background: rgba(255, 255, 255, 0.3);
+ border-radius: 2px;
+ }
+
+ /* Adjust padding for mobile bottom sheet */
+ .options-dropdown .options-grid {
+ padding: 32px 20px 20px;
+ }
+
+ /* Fix the container width to viewport */
+ .links-container {
+ overflow: visible;
+ /* Allow dropdown to overflow */
+ max-width: 100vw;
+ /* Never exceed viewport width */
+ width: 100%;
+ /* Take full available width */
+ padding: 0;
+ border-radius: 0;
+ margin: 0;
+ backdrop-filter: none;
+ /* Remove backdrop-filter to allow fixed positioning */
+ }
+
+ .toolbar {
+ overflow: visible;
+ /* Allow dropdown to overflow */
+ width: 100%;
+ padding: 20px 16px;
+ gap: 12px;
+ }
+
+ .toolbar-actions {
+ overflow: visible;
+ }
+
+ /* ONLY the list-container should scroll horizontally */
+ .list-container {
+ overflow-x: auto;
+ overflow-y: visible;
+ -webkit-overflow-scrolling: touch;
+ /* Smooth scrolling on iOS */
+ padding: 0;
+ /* Remove any padding that might interfere */
+ width: 100%;
+ /* Constrain to parent width */
+ max-width: 100%;
+ /* Never exceed parent */
+ }
+
+ /* Style scrollbar */
+ .list-container::-webkit-scrollbar {
+ height: 6px;
+ }
+
+ .list-container::-webkit-scrollbar-track {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 3px;
+ }
+
+ .list-container::-webkit-scrollbar-thumb {
+ background: rgba(124, 58, 237, 0.6);
+ border-radius: 3px;
+ }
+
+ .list-container::-webkit-scrollbar-thumb:hover {
+ background: rgba(124, 58, 237, 0.8);
+ }
+
+ /* Table stays in table format, just scrollable */
+ .links-table {
+ min-width: 1000px;
+ /* Ensure table doesn't shrink below readable width */
+ width: max-content;
+ /* Let table be as wide as it needs */
+ }
+
+ /* Fix column widths - no minmax, just fixed widths */
+ .table-header,
+ .table-row {
+ grid-template-columns: 180px 280px 130px 130px 90px 110px;
+ width: 100%;
+ }
+
+ /* Adjust font sizes for mobile */
+ .header-cell {
+ font-size: 11px;
+ padding: 12px 10px;
+ white-space: nowrap;
+ }
+
+ .table-cell {
+ padding: 14px 10px;
+ font-size: 13px;
+ }
+
+ .col-short-url .link-short {
+ font-size: 13px;
+ font-weight: 600;
+ }
+
+ .col-long-url .link-long {
+ font-size: 12px;
+ /* Keep ellipsis for long URLs */
+ }
+
+ /* Make date columns compact */
+ .col-created,
+ .col-last-click {
+ font-size: 12px;
+ }
+
+ /* Make clicks number more prominent on mobile */
+ .col-clicks {
+ font-size: 15px;
+ font-weight: 700;
+ }
+
+ /* Slightly smaller badges on mobile */
+ .badge {
+ width: 22px;
+ height: 22px;
+ }
+
+ .badge i {
+ font-size: 13px;
+ }
+
+ /* Empty state adjustments */
+ .empty {
+ min-width: 100%;
+ /* Prevent empty state from being cut off */
+ }
+
+ /* Pagination adjustments */
+ .pagination-bar {
+ padding: 12px 16px;
+ flex-wrap: wrap;
+ gap: 12px;
+ overflow-x: hidden;
+ /* Pagination should not cause horizontal scroll */
+ }
+
+ .pagination-info {
+ font-size: 13px;
+ }
+
+ .pagination-controls button {
+ padding: 8px 12px;
+ font-size: 13px;
+ }
+}
\ No newline at end of file
diff --git a/static/css/dashboard/statistics.css b/static/css/dashboard/statistics.css
new file mode 100644
index 00000000..b15bafe1
--- /dev/null
+++ b/static/css/dashboard/statistics.css
@@ -0,0 +1,2012 @@
+/* Statistics Dashboard Styles - Based on stats-view.css */
+
+/* Page Header */
+.page-header {
+ margin-bottom: 20px;
+}
+
+.page-header-content {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 20px;
+}
+
+.page-header-text {
+ flex: 1;
+}
+
+.page-title {
+ font-size: 32px;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin: 0 0 8px 0;
+}
+
+.page-subtitle {
+ font-size: 16px;
+ color: var(--text-secondary);
+ margin: 0;
+}
+
+/* Controls Bar */
+.controls-bar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+}
+
+.controls-left {
+ display: flex;
+ align-items: center;
+}
+
+.controls-actions {
+ display: flex;
+ gap: 15px;
+ align-items: center;
+}
+
+.time-selector {
+ padding: 10px 15px;
+ border-radius: 12px;
+ color: white;
+ background: rgba(255, 255, 255, 0.15);
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ box-shadow: rgba(0, 0, 0, 0.16) 0 10px 36px 0px, rgba(0, 0, 0, 0.06) 0 0 0 1px;
+}
+
+.time-selector:hover {
+ background: rgba(255, 255, 255, 0.25);
+ transition: background-color 0.2s ease-in-out;
+ cursor: pointer;
+}
+
+.time-selector:focus {
+ outline: none;
+}
+
+.time-selector option {
+ color: black;
+}
+
+/* Refresh Control */
+.refresh-control {
+ display: flex;
+ align-items: center;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ padding: 2px;
+}
+
+.refresh-btn {
+ background: transparent;
+ border: none;
+ color: rgba(255, 255, 255, 0.75);
+ padding: 6px 8px;
+ border-radius: 6px;
+ font-size: 16px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.refresh-btn:hover {
+ color: rgba(255, 255, 255, 0.95);
+ background-color: rgba(255, 255, 255, 0.1);
+}
+
+.export-btn {
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ color: rgba(255, 255, 255, 0.75);
+ padding: 8px 12px;
+ font-size: 16px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ min-height: 36px;
+}
+
+.export-btn-wrapper {
+ position: relative;
+ display: inline-block;
+}
+
+.export-btn:hover {
+ color: rgba(255, 255, 255, 0.95);
+ background-color: rgba(255, 255, 255, 0.15);
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.export-btn.active {
+ background: rgba(124, 58, 237, 0.15);
+ border-color: rgba(124, 58, 237, 0.4);
+ color: rgba(255, 255, 255, 0.95);
+}
+
+/* Export Dropdown Menu */
+.export-dropdown-menu {
+ position: absolute;
+ background: rgba(15, 20, 35, 0.98);
+ backdrop-filter: blur(20px) saturate(1.2);
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 12px;
+ padding: 8px;
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(-10px);
+ transition: all 0.2s ease;
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
+ z-index: 1000;
+ min-width: 200px;
+ top: calc(100% + 8px);
+ left: -20px;
+}
+
+.export-dropdown-menu.active {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+}
+
+.export-menu-item {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 14px;
+ border-radius: 8px;
+ color: rgba(255, 255, 255, 0.85);
+ background: transparent;
+ border: none;
+ text-align: left;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.export-menu-item:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: white;
+}
+
+.export-menu-item i {
+ font-size: 18px;
+ flex-shrink: 0;
+}
+
+
+
+.separator {
+ width: 1px;
+ height: 18px;
+ background: rgba(255, 255, 255, 0.2);
+ margin: 0 6px;
+}
+
+.auto-refresh-dropdown {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.auto-refresh-btn {
+ background: transparent;
+ border: none;
+ color: rgba(255, 255, 255, 0.75);
+ padding: 6px 8px;
+ border-radius: 6px;
+ font-size: 14px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+ font-weight: 500;
+}
+
+.auto-refresh-btn:hover {
+ color: rgba(255, 255, 255, 0.95);
+ background-color: rgba(255, 255, 255, 0.1);
+}
+
+.auto-refresh-btn.active {
+ /* background-color: rgba(124, 58, 237, 0.15); */
+ color: rgba(255, 255, 255, 0.95);
+}
+
+.auto-refresh-btn.active .ti-chevron-down {
+ transform: rotate(180deg);
+}
+
+.auto-refresh-btn .ti-chevron-down {
+ transition: transform 0.2s ease;
+}
+
+.auto-refresh-dropdown .dropdown-menu {
+ position: absolute;
+ top: calc(100% + 10px);
+ right: 0;
+ background: rgba(15, 20, 35, 0.95);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 8px;
+ min-width: 80px;
+ z-index: 1000;
+ visibility: hidden;
+ box-shadow: rgba(0, 0, 0, 0.3) 0 20px 40px 0px;
+ backdrop-filter: blur(16px);
+ overflow: hidden;
+ opacity: 0;
+ transform: translateY(-8px);
+ transition: opacity 0.15s ease-out, transform 0.15s ease-out, visibility 0s linear 0.15s;
+}
+
+.auto-refresh-dropdown .dropdown-menu.show {
+ visibility: visible;
+ opacity: 1;
+ transform: translateY(0);
+ transition: opacity 0.15s ease-out, transform 0.15s ease-out, visibility 0s linear 0s;
+}
+
+.auto-refresh-dropdown .dropdown-item {
+ padding: 8px 12px;
+ color: rgba(255, 255, 255, 0.8);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ transition: all 0.2s ease;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.auto-refresh-dropdown .dropdown-item:hover {
+ background: rgba(255, 255, 255, 0.12);
+ color: rgba(255, 255, 255, 0.95);
+}
+
+.auto-refresh-dropdown .dropdown-item:first-child {
+ border-radius: 8px 8px 0 0;
+}
+
+.auto-refresh-dropdown .dropdown-item:last-child {
+ border-radius: 0 0 8px 8px;
+}
+
+/* Filters Dropdown Container */
+.filters-dropdown-container {
+ position: relative;
+}
+
+.filters-btn {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 14px;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ color: rgba(255, 255, 255, 0.85);
+ font-size: 14px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-weight: 500;
+}
+
+.filters-btn:hover {
+ background: rgba(255, 255, 255, 0.15);
+ border-color: rgba(255, 255, 255, 0.2);
+ color: rgba(255, 255, 255, 0.95);
+}
+
+.filters-btn.active {
+ background: rgba(124, 58, 237, 0.15);
+ border-color: rgba(124, 58, 237, 0.4);
+ color: rgba(255, 255, 255, 0.95);
+}
+
+.filters-text {
+ font-weight: 500;
+}
+
+.active-filters-count {
+ background: rgba(124, 58, 237, 0.9);
+ color: white;
+ border-radius: 10px;
+ padding: 2px 6px;
+ font-size: 11px;
+ font-weight: 600;
+ min-width: 18px;
+ text-align: center;
+ line-height: 1.2;
+}
+
+.filters-chevron {
+ font-size: 12px;
+ transition: transform 0.2s ease;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.filters-btn.active .filters-chevron {
+ transform: rotate(180deg);
+ color: rgba(255, 255, 255, 0.8);
+}
+
+/* Floating Filters Dropdown */
+.filters-dropdown {
+ position: absolute;
+ top: calc(100% + 10px);
+ left: 0;
+ width: 260px;
+ max-width: 90vw;
+ background: rgba(15, 20, 35, 0.95);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 12px;
+ backdrop-filter: blur(16px);
+ box-shadow: rgba(0, 0, 0, 0.3) 0 20px 40px 0px;
+ z-index: 9999;
+ visibility: hidden;
+ overflow: hidden;
+ opacity: 0;
+ transform: translateY(-8px);
+ transition: opacity 0.15s ease-out, transform 0.15s ease-out, visibility 0s linear 0.15s;
+}
+
+.filters-dropdown.show {
+ visibility: visible;
+ opacity: 1;
+ transform: translateY(0);
+ transition: opacity 0.15s ease-out, transform 0.15s ease-out, visibility 0s linear 0s;
+}
+
+@keyframes filtersDropdownSlideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Filter Types List */
+.filter-types-list {
+ padding: 6px;
+}
+
+/* Subtle crossfade transitions between filter views */
+.view-enter {
+ opacity: 0;
+}
+
+.view-enter-active {
+ opacity: 1;
+ transition: opacity 0.18s ease;
+}
+
+.view-exit-active {
+ opacity: 0;
+ transition: opacity 0.14s ease;
+}
+
+.filter-type-item {
+ display: flex;
+ align-items: center;
+ padding: 12px 10px;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.filter-type-item:hover:not(.clear-all-item) {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.filter-type-item.clear-all-item {
+ color: rgba(239, 68, 68, 0.9);
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ margin-top: 8px;
+}
+
+.filter-type-item.clear-all-item:hover {
+ background: rgba(239, 68, 68, 0.1);
+}
+
+.filter-type-content {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+}
+
+.filter-type-info {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.filter-type-info i {
+ font-size: 16px;
+ color: rgba(255, 255, 255, 0.7);
+ width: 20px;
+ text-align: center;
+}
+
+.filter-type-label {
+ font-size: 14px;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.filter-type-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.filter-count {
+ font-size: 10px;
+ color: rgba(255, 255, 255, 0.6);
+ background: rgba(255, 255, 255, 0.08);
+ padding: 4px 8px;
+ border-radius: 12px;
+ min-width: 24px;
+ text-align: center;
+}
+
+.filter-type-status i {
+ font-size: 14px;
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.filter-actions-separator {
+ height: 1px;
+ background: rgba(255, 255, 255, 0.1);
+ margin: 8px 16px;
+}
+
+/* Filter Values View */
+.filter-values-view {
+ padding: 8px;
+}
+
+.filter-values-top {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 8px;
+}
+
+.back-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ background: rgba(255, 255, 255, 0.08);
+ border: none;
+ border-radius: 6px;
+ color: rgba(255, 255, 255, 0.8);
+ font-size: 16px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ flex-shrink: 0;
+}
+
+.back-btn:hover {
+ background: rgba(255, 255, 255, 0.12);
+ color: rgba(255, 255, 255, 0.95);
+}
+
+.filter-values-search {
+ position: relative;
+ flex: 1;
+}
+
+.filter-values-search i {
+ position: absolute;
+ left: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 14px;
+}
+
+.values-search-input {
+ width: 100%;
+ height: 36px;
+ padding: 10px 12px 10px 36px;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 6px;
+ color: rgba(255, 255, 255, 0.9);
+ font-size: 14px;
+ transition: all 0.2s ease;
+}
+
+.values-search-input:focus {
+ outline: none;
+ border-color: #7c3aed;
+ background: rgba(255, 255, 255, 0.12);
+}
+
+.filter-values-list {
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.filter-values-list .option-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 10px;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ margin-bottom: 2px;
+}
+
+.filter-values-list .option-item:hover {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.filter-values-list .empty {
+ color: white;
+ padding: 20px;
+ text-align: center;
+}
+
+.filters-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 16px;
+ margin-bottom: 16px;
+}
+
+.filter-group {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.filter-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.8);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 4px;
+}
+
+.filter-label i {
+ font-size: 14px;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+/* Multi-Select Components */
+.multi-select-wrapper {
+ position: relative;
+ z-index: 1;
+}
+
+.multi-select-wrapper:has(.multi-select-dropdown.show) {
+ z-index: 10000;
+}
+
+/* Fallback for browsers that don't support :has() */
+.multi-select-wrapper.active {
+ z-index: 10000;
+}
+
+.multi-select-trigger {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 8px;
+ color: rgba(255, 255, 255, 0.9);
+ font-size: 14px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ text-align: left;
+}
+
+.multi-select-trigger:hover {
+ background: rgba(255, 255, 255, 0.12);
+ border-color: rgba(255, 255, 255, 0.25);
+}
+
+.multi-select-trigger.active {
+ background: rgba(124, 58, 237, 0.15);
+ border-color: rgba(124, 58, 237, 0.4);
+}
+
+.selected-summary {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.multi-select-trigger i {
+ font-size: 14px;
+ color: rgba(255, 255, 255, 0.6);
+ transition: transform 0.2s ease;
+}
+
+.multi-select-trigger.active i {
+ transform: rotate(180deg);
+}
+
+/* Dropdown Styling */
+.multi-select-dropdown {
+ position: absolute;
+ top: calc(100% + 8px);
+ left: 0;
+ right: 0;
+ background: rgba(20, 25, 40, 0.98);
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 12px;
+ box-shadow: rgba(0, 0, 0, 0.3) 0 20px 40px;
+ backdrop-filter: blur(20px);
+ z-index: 9999;
+ max-height: 320px;
+ visibility: hidden;
+ overflow: hidden;
+ min-width: 280px;
+ opacity: 0;
+ transform: translateY(-8px);
+ transition: opacity 0.15s ease-out, transform 0.15s ease-out, visibility 0s linear 0.15s;
+}
+
+.multi-select-dropdown.show {
+ visibility: visible;
+ opacity: 1;
+ transform: translateY(0);
+ transition: opacity 0.15s ease-out, transform 0.15s ease-out, visibility 0s linear 0s;
+}
+
+@keyframes dropdownSlideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.dropdown-header {
+ padding: 16px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.02);
+ border-radius: 12px 12px 0 0;
+}
+
+.dropdown-search {
+ position: relative;
+ display: flex;
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+.dropdown-search i {
+ position: absolute;
+ left: 12px;
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 14px;
+ pointer-events: none;
+}
+
+.search-input {
+ width: 100%;
+ padding: 10px 12px 10px 36px;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 8px;
+ color: rgba(255, 255, 255, 0.95);
+ font-size: 14px;
+ transition: all 0.2s ease;
+}
+
+.search-input:focus {
+ outline: none;
+ border-color: #7c3aed;
+ background: rgba(255, 255, 255, 0.15);
+ box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.2);
+}
+
+.search-input::placeholder {
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.dropdown-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.select-all-btn,
+.clear-all-btn {
+ padding: 8px 14px;
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.25);
+ border-radius: 6px;
+ color: rgba(255, 255, 255, 0.8);
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.select-all-btn:hover,
+.clear-all-btn:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.95);
+ border-color: rgba(255, 255, 255, 0.4);
+ transform: translateY(-1px);
+}
+
+.select-all-btn:hover {
+ background: rgba(34, 197, 94, 0.15);
+ border-color: rgba(34, 197, 94, 0.5);
+ color: rgb(34, 197, 94);
+}
+
+.clear-all-btn:hover {
+ background: rgba(239, 68, 68, 0.15);
+ border-color: rgba(239, 68, 68, 0.5);
+ color: rgb(239, 68, 68);
+}
+
+/* Options List */
+.options-list {
+ max-height: 200px;
+ overflow-y: auto;
+ padding: 8px;
+ background: transparent;
+}
+
+.option-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ margin-bottom: 3px;
+ min-height: 44px;
+ position: relative;
+}
+
+.option-item:hover {
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.option-item:last-child {
+ margin-bottom: 0;
+}
+
+.option-item input[type="checkbox"] {
+ display: none;
+}
+
+.checkmark {
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(255, 255, 255, 0.4);
+ border-radius: 4px;
+ position: relative;
+ transition: all 0.2s ease;
+ flex-shrink: 0;
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.option-item:hover .checkmark {
+ border-color: rgba(255, 255, 255, 0.6);
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.option-item input[type="checkbox"]:checked+.checkmark {
+ background: #7c3aed;
+ border-color: #7c3aed;
+ box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.2);
+ transform: scale(1.1);
+}
+
+.option-item input[type="checkbox"]:checked~.option-text {
+ color: rgba(255, 255, 255, 1);
+ font-weight: 600;
+}
+
+.option-item input[type="checkbox"]:checked~.option-count {
+ background: rgba(124, 58, 237, 0.2);
+ color: rgba(124, 58, 237, 1);
+ border: 1px solid rgba(124, 58, 237, 0.3);
+}
+
+.option-item input[type="checkbox"]:checked+.checkmark::after {
+ content: '✓';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ color: white;
+ font-size: 13px;
+ font-weight: bold;
+ line-height: 1;
+}
+
+.option-text {
+ flex: 1;
+ color: rgba(255, 255, 255, 0.95);
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1.4;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.option-count {
+ color: rgba(255, 255, 255, 0.6);
+ font-size: 10px;
+ font-weight: 600;
+ background: rgba(255, 255, 255, 0.08);
+ padding: 4px 8px;
+ border-radius: 10px;
+ min-width: 24px;
+ text-align: center;
+}
+
+/* Country-specific styling */
+.countries-dropdown .option-item {
+ position: relative;
+}
+
+.country-flag {
+ width: 20px;
+ height: 15px;
+ font-size: 16px;
+ flex-shrink: 0;
+}
+
+/* Filter Actions */
+.filters-actions {
+ display: flex;
+ justify-content: center;
+ padding-top: 16px;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+
+
+.clear-all-filters-btn {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 20px;
+ background: rgba(239, 68, 68, 0.15);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ border-radius: 8px;
+ color: rgb(239, 68, 68);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.clear-all-filters-btn:hover {
+ background: rgba(239, 68, 68, 0.25);
+ border-color: rgba(239, 68, 68, 0.5);
+}
+
+.clear-all-filters-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ background: rgba(255, 255, 255, 0.05);
+ border-color: rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.4);
+}
+
+/* Scrollbar styling for options list */
+.options-list::-webkit-scrollbar {
+ width: 6px;
+}
+
+.options-list::-webkit-scrollbar-track {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 3px;
+}
+
+.options-list::-webkit-scrollbar-thumb {
+ background: rgba(124, 58, 237, 0.4);
+ border-radius: 3px;
+}
+
+.options-list::-webkit-scrollbar-thumb:hover {
+ background: rgba(124, 58, 237, 0.6);
+}
+
+/* Loading state */
+.options-list.loading,
+.options-list .loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100px;
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 14px;
+}
+
+/* Empty state */
+.options-list.empty,
+.options-list .empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 80px;
+ color: rgba(255, 255, 255, 0.5);
+ font-style: italic;
+ font-size: 14px;
+}
+
+/* Main Stats Container */
+.main-stats-container {
+ display: flex;
+ justify-content: space-between;
+ gap: 20px;
+ margin-bottom: 20px;
+}
+
+.card-parent {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
+ box-shadow: rgba(0, 0, 0, 0.24) 0 3px 8px;
+ border: rgba(255, 255, 255, 0.05) 1px solid;
+ border-radius: 6px;
+ flex: 1;
+}
+
+.clicks-counter-parent {
+ flex: 3 !important;
+}
+
+/* Clicks Counter Section - Takes most of the width */
+.clicks-counter-section {
+ background-color: rgba(255, 255, 255, 0.02);
+ border-radius: 6px;
+ backdrop-filter: blur(20px);
+ padding: 20px 20px 5px 20px;
+ width: 100%;
+ height: 100%;
+}
+
+/* Summary Stats - Takes remaining width */
+.summary-stats {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ flex: 1;
+ /* Takes 1/4 of the width */
+}
+
+.stat-card {
+ background-color: rgba(255, 255, 255, 0.02);
+ border-radius: 6px;
+ backdrop-filter: blur(20px);
+ width: 100%;
+ height: 100%;
+ padding: 14px 15px;
+ box-shadow: rgba(0, 0, 0, 0.24) 0 3px 8px;
+ text-align: center;
+}
+
+.stat-value {
+ font-size: 1.8rem;
+ font-weight: bold;
+ color: white;
+ margin-bottom: 5px;
+}
+
+.stat-label {
+ font-size: 0.9rem;
+ color: rgba(255, 255, 255, 0.8);
+ margin-bottom: 5px;
+}
+
+.stat-change {
+ font-size: 0.8rem;
+ font-weight: bold;
+}
+
+.stat-change.positive {
+ color: #4CAF50;
+}
+
+.stat-change.negative {
+ color: #f44336;
+}
+
+.stat-change.neutral {
+ color: #ff9800;
+}
+
+/* Charts Container */
+.charts-container {
+ display: flex;
+ justify-content: space-between;
+ gap: 20px;
+ margin-bottom: 20px;
+}
+
+.chart-section {
+ background-color: rgba(255, 255, 255, 0.02);
+ border-radius: 6px;
+ backdrop-filter: blur(20px);
+ padding: 20px 20px 5px 20px;
+ width: 100%;
+ min-height: 400px;
+}
+
+/* Chart Container */
+.chart-container {
+ height: 350px;
+ position: relative;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.chart-container canvas {
+ max-width: 100% !important;
+ max-height: 100% !important;
+ flex: 1;
+ transition: opacity 0.3s ease, transform 0.3s ease;
+}
+
+.chart-container #countryChart {
+ flex: 1;
+ height: 100%;
+ transition: opacity 0.3s ease, transform 0.3s ease;
+}
+
+/* Fade transition classes */
+.chart-view-enter {
+ opacity: 0;
+ transform: translateY(10px);
+}
+
+.chart-view-enter-active {
+ opacity: 1;
+ transform: translateY(0);
+ transition: opacity 0.3s ease, transform 0.3s ease;
+}
+
+.chart-view-exit {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.chart-view-exit-active {
+ opacity: 0;
+ transform: translateY(-10px);
+ transition: opacity 0.3s ease, transform 0.3s ease;
+}
+
+/* Chart Section Headers */
+.chart-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 15px;
+}
+
+.chart-header h2 {
+ margin: 0;
+ font-size: 1.2rem;
+ color: white;
+}
+
+.chart-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.chart-actions>* {
+ flex-shrink: 0;
+}
+
+/* Cascade Select Buttons */
+.cascade-select {
+ position: relative;
+ display: inline-block;
+ transition: opacity 0.3s ease;
+}
+
+.cascade-select[style*="pointer-events: none"] {
+ cursor: not-allowed;
+}
+
+.cascade-btn {
+ padding: 8px 12px;
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ background: rgba(255, 255, 255, 0.15);
+ color: white;
+ cursor: pointer;
+ font-size: 14px;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.cascade-btn:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.25);
+}
+
+.cascade-btn.active:not(:disabled) {
+ background: rgba(124, 58, 237, 0.3);
+ border-color: #7c3aed;
+}
+
+.cascade-btn:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+ background: rgba(255, 255, 255, 0.1) !important;
+ border-color: rgba(255, 255, 255, 0.1) !important;
+}
+
+.cascade-dropdown {
+ position: absolute;
+ top: calc(100% + 5px);
+ right: 0;
+ background: rgba(30, 35, 50, 0.98);
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 8px;
+ box-shadow: rgba(0, 0, 0, 0.3) 0 10px 30px;
+ backdrop-filter: blur(10px);
+ z-index: 1000;
+ min-width: 180px;
+ visibility: hidden;
+ overflow: hidden;
+ opacity: 0;
+ transform: translateY(-8px);
+ transition: opacity 0.15s ease-out, transform 0.15s ease-out, visibility 0s linear 0.15s;
+}
+
+.cascade-dropdown.show {
+ visibility: visible;
+ opacity: 1;
+ transform: translateY(0);
+ transition: opacity 0.15s ease-out, transform 0.15s ease-out, visibility 0s linear 0s;
+}
+
+.cascade-option {
+ width: 100%;
+ padding: 12px 16px;
+ border: none;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.8);
+ cursor: pointer;
+ font-size: 14px;
+ text-align: left;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.cascade-option:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: white;
+}
+
+.cascade-option.active {
+ background: rgba(124, 58, 237, 0.3);
+ color: white;
+}
+
+.cascade-option i {
+ font-size: 16px;
+ width: 20px;
+}
+
+.cascade-option span {
+ flex: 1;
+}
+
+/* Headings */
+.chart-section h2,
+.clicks-counter-section h2 {
+ margin: 0;
+ margin-left: 5px;
+ color: white;
+ font-weight: 400;
+ font-size: 1em;
+}
+
+/* Canvas Heights */
+#timeSeriesChart,
+#countryChart,
+#referrerChart,
+#browserChart,
+#osChart,
+#deviceChart,
+#keyChart {
+ width: 100% !important;
+ height: 100% !important;
+ max-width: 100% !important;
+ max-height: 100% !important;
+}
+
+/* Modal Styles */
+.modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.8);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+.modal-content {
+ background: rgba(255, 255, 255, 0.1);
+ padding: 30px;
+ border-radius: 15px;
+ box-shadow: rgba(0, 0, 0, 0.24) 0 3px 8px;
+ border: rgba(255, 255, 255, 0.05) 1px solid;
+ min-width: 400px;
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+}
+
+.modal-header h3 {
+ color: white;
+ margin: 0;
+}
+
+.modal-close {
+ background: none;
+ border: none;
+ color: white;
+ font-size: 1.5rem;
+ cursor: pointer;
+ padding: 5px;
+}
+
+.date-inputs {
+ display: flex;
+ gap: 20px;
+ margin-bottom: 20px;
+}
+
+.input-group {
+ flex: 1;
+}
+
+.input-group label {
+ display: block;
+ color: white;
+ margin-bottom: 5px;
+ font-size: 0.9rem;
+}
+
+.date-input {
+ width: 100%;
+ padding: 10px;
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ background: rgba(255, 255, 255, 0.1);
+ color: white;
+ font-size: 0.9rem;
+}
+
+.date-input:focus {
+ outline: none;
+ border-color: rgba(255, 255, 255, 0.4);
+}
+
+.modal-actions {
+ display: flex;
+ gap: 15px;
+ justify-content: flex-end;
+}
+
+.btn-secondary,
+.btn-primary {
+ padding: 10px 20px;
+ border-radius: 8px;
+ border: none;
+ cursor: pointer;
+ font-size: 0.9rem;
+}
+
+.btn-secondary {
+ background: rgba(255, 255, 255, 0.15);
+ color: white;
+}
+
+.btn-primary {
+ background: #7c3aed;
+ color: white;
+}
+
+.btn-secondary:hover,
+.btn-primary:hover {
+ opacity: 0.8;
+}
+
+.anychart-credits {
+ display: none !important;
+}
+
+/* Responsive Design */
+@media screen and (max-width: 1200px) {
+ .main-stats-container {
+ flex-direction: column;
+ }
+
+ .clicks-counter-section {
+ flex: none;
+ }
+
+ .summary-stats {
+ flex-direction: row;
+ gap: 15px;
+ }
+
+ .stat-card {
+ flex: 1;
+ }
+}
+
+@media screen and (max-width: 1000px) {
+ .charts-container {
+ flex-direction: column;
+ }
+
+ .chart-section {
+ min-height: 350px;
+ }
+
+ .controls-bar {
+ gap: 15px;
+ align-items: stretch;
+ }
+
+ .controls-left {
+ justify-content: flex-start;
+ }
+
+ .controls-actions {
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ gap: 10px;
+ }
+
+ .filters-grid {
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 14px;
+ }
+}
+
+@media screen and (max-width: 768px) {
+ .statistics-dashboard {
+ padding: 0 10px;
+ }
+
+ .summary-stats {
+ flex-direction: column;
+ }
+
+ .page-header-content {
+ flex-direction: column;
+ gap: 20px;
+ text-align: left;
+ }
+
+ .page-header-text {
+ text-align: left;
+ }
+
+ .controls-bar {
+ flex-direction: column;
+ gap: 12px;
+ align-items: stretch;
+ }
+
+ .controls-left {
+ width: 100%;
+ }
+
+ .controls-actions {
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ }
+
+ /* Mobile Bottom Sheet for Filters Dropdown */
+ .filters-dropdown {
+ position: fixed;
+ top: auto;
+ right: 0;
+ left: 0;
+ bottom: 0;
+ width: 100%;
+ max-width: 100%;
+ max-height: 85vh;
+
+ background: rgba(15, 20, 35, 0.98);
+ backdrop-filter: blur(20px);
+ border-radius: 24px 24px 0 0;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-bottom: none;
+ box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.4);
+
+ display: block !important;
+ /* Override desktop display: none */
+ visibility: hidden;
+ opacity: 0;
+ transform: translateY(100%);
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ visibility 0s linear 0.3s;
+ z-index: 9999;
+
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ animation: none !important;
+ /* Remove desktop animation */
+ }
+
+ .filters-dropdown.show {
+ visibility: visible;
+ opacity: 1;
+ transform: translateY(0);
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ visibility 0s linear 0s;
+ animation: none !important;
+ /* Remove desktop animation */
+ }
+
+ /* Backdrop overlay */
+ .filters-dropdown::before {
+ content: '';
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(15, 20, 35, 0.95);
+ z-index: -1;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ pointer-events: none;
+ }
+
+ .filters-dropdown.show::before {
+ opacity: 1;
+ pointer-events: auto;
+ }
+
+ /* Handle bar */
+ .filters-dropdown::after {
+ content: '';
+ position: absolute;
+ top: 12px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 36px;
+ height: 4px;
+ background: rgba(255, 255, 255, 0.3);
+ border-radius: 2px;
+ z-index: 1;
+ }
+
+ .filter-types-list,
+ .filter-values-view {
+ padding: 32px 20px 20px;
+ min-height: 300px;
+ }
+
+ /* Mobile Bottom Sheet for Auto-Refresh Dropdown */
+ .auto-refresh-dropdown .dropdown-menu {
+ position: fixed;
+ top: auto;
+ right: 0;
+ left: 0;
+ bottom: 0;
+ width: 100%;
+ max-width: 100%;
+ border-radius: 24px 24px 0 0;
+ box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.4);
+ display: block;
+ visibility: hidden;
+ opacity: 0;
+ transform: translateY(100%);
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ visibility 0s linear 0.3s;
+ min-width: unset;
+ }
+
+ .auto-refresh-dropdown .dropdown-menu.show {
+ visibility: visible;
+ opacity: 1;
+ transform: translateY(0);
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ visibility 0s linear 0s;
+ }
+
+ /* Backdrop for auto-refresh */
+ .auto-refresh-dropdown .dropdown-menu::before {
+ content: '';
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(15, 20, 35, 0.95);
+ z-index: -1;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ pointer-events: none;
+ }
+
+ .auto-refresh-dropdown .dropdown-menu.show::before {
+ opacity: 1;
+ pointer-events: auto;
+ }
+
+ /* Handle bar for auto-refresh */
+ .auto-refresh-dropdown .dropdown-menu::after {
+ content: '';
+ position: absolute;
+ top: 12px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 36px;
+ height: 4px;
+ background: rgba(255, 255, 255, 0.3);
+ border-radius: 2px;
+ }
+
+ .auto-refresh-dropdown .dropdown-item {
+ padding: 16px 20px;
+ font-size: 16px;
+ }
+
+ .auto-refresh-dropdown .dropdown-item:first-child {
+ margin-top: 24px;
+ }
+
+ /* Multi-select dropdowns as bottom sheets */
+ .multi-select-dropdown {
+ position: fixed;
+ top: auto;
+ right: 0;
+ left: 0;
+ bottom: 0;
+ width: 100%;
+ max-width: 100%;
+ max-height: 85vh;
+ border-radius: 24px 24px 0 0;
+ box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.4);
+ display: block;
+ visibility: hidden;
+ opacity: 0;
+ transform: translateY(100%);
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ visibility 0s linear 0.3s;
+ min-width: unset;
+ }
+
+ .multi-select-dropdown.show {
+ visibility: visible;
+ opacity: 1;
+ transform: translateY(0);
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+ visibility 0s linear 0s;
+ }
+
+ /* Backdrop for multi-select */
+ .multi-select-dropdown::before {
+ content: '';
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.6);
+ z-index: -1;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ pointer-events: none;
+ }
+
+ .multi-select-dropdown.show::before {
+ opacity: 1;
+ pointer-events: auto;
+ }
+
+ /* Handle bar for multi-select */
+ .multi-select-dropdown::after {
+ content: '';
+ position: absolute;
+ top: 12px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 36px;
+ height: 4px;
+ background: rgba(255, 255, 255, 0.3);
+ border-radius: 2px;
+ z-index: 1;
+ }
+
+ .dropdown-header {
+ padding: 32px 20px 16px;
+ }
+}
+
+@media screen and (max-width: 600px) {
+ .statistics-dashboard {
+ padding: 0 5px;
+ }
+
+ .page-title {
+ font-size: 1.5rem;
+ }
+
+ .page-header-content {
+ flex-direction: column;
+ gap: 15px;
+ text-align: left;
+ /* Left align instead of default center */
+ }
+
+ .controls-bar {
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .controls-left {
+ justify-content: start;
+ }
+
+ .controls-actions {
+ justify-content: start;
+ gap: 10px;
+ width: 100%;
+ }
+
+ .date-inputs {
+ flex-direction: column;
+ gap: 15px;
+ }
+
+ .modal-content {
+ min-width: 300px;
+ margin: 20px;
+ }
+
+
+ .filters-grid {
+ grid-template-columns: 1fr;
+ gap: 12px;
+ }
+
+ .multi-select-dropdown {
+ max-height: 240px;
+ }
+}
+
+/* Chart Actions Container */
+.chart-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.chart-actions>* {
+ flex-shrink: 0;
+}
+
+/* Table View Button Styles */
+.table-view-btn {
+ background: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 8px;
+ padding: 8px 12px;
+ color: rgba(255, 255, 255, 0.7);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+}
+
+.table-view-btn:hover {
+ background: rgba(255, 255, 255, 0.15);
+ color: rgba(255, 255, 255, 0.9);
+ border-color: rgba(255, 255, 255, 0.3);
+}
+
+.table-view-btn.active {
+ background: rgba(139, 92, 246, 0.2);
+ border-color: rgba(139, 92, 246, 0.4);
+ color: rgba(139, 92, 246, 1);
+}
+
+.table-view-btn.active:hover {
+ background: rgba(139, 92, 246, 0.3);
+ border-color: rgba(139, 92, 246, 0.5);
+}
+
+/* new table full width styles (experimental) */
+
+.stats-chart {
+ padding: 0 20px 20px 20px;
+}
+
+.chart-section,
+.clicks-counter-section {
+ padding: 0;
+}
+
+.chart-header {
+ margin-bottom: 0;
+ padding: 20px 15px 25px;
+}
+
+.chart-container {
+ margin: 0;
+ padding: 0;
+}
+
+/* Statistics Table Styles */
+.stats-table {
+ overflow: hidden;
+ overflow-y: scroll;
+ border-radius: 6px;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ background: rgb(20, 25, 38);
+ backdrop-filter: blur(12px);
+ margin: 0;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ transition: opacity 0.3s ease, transform 0.3s ease;
+}
+
+/* Table transition classes */
+.table-view-enter {
+ opacity: 0;
+ transform: translateY(10px);
+}
+
+.table-view-enter-active {
+ opacity: 1;
+ transform: translateY(0);
+ transition: opacity 0.3s ease, transform 0.3s ease;
+}
+
+.table-view-exit {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.table-view-exit-active {
+ opacity: 0;
+ transform: translateY(-10px);
+ transition: opacity 0.3s ease, transform 0.3s ease;
+}
+
+.stats-table .table-header {
+ display: grid;
+ grid-template-columns: 1fr 140px 140px;
+ background: rgba(255, 255, 255, 0.1);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.15);
+ flex-shrink: 0;
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ backdrop-filter: blur(12px);
+}
+
+.stats-table .header-cell {
+ padding: 16px 12px;
+ font-size: 12px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.8);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ border-right: 1px solid rgba(255, 255, 255, 0.08);
+ overflow: hidden;
+ overflow-y: scroll;
+ display: flex;
+ align-items: center;
+}
+
+.stats-table .header-cell:last-child {
+ border-right: none;
+}
+
+.stats-table .table-body {
+ overflow-y: auto;
+ flex: 1;
+ min-height: 0;
+}
+
+.stats-table .table-row {
+ display: grid;
+ grid-template-columns: 1fr 140px 140px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ transition: background 0.2s ease;
+}
+
+.stats-table .table-row:hover {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.stats-table .table-row:last-child {
+ border-bottom: none;
+}
+
+.stats-table .table-cell {
+ padding: 14px 12px;
+ display: flex;
+ align-items: center;
+ border-right: 1px solid rgba(255, 255, 255, 0.05);
+ min-height: 52px;
+ min-width: 0;
+ overflow: hidden;
+ color: rgba(255, 255, 255, 0.9);
+ font-size: 14px;
+}
+
+.stats-table .table-cell:last-child {
+ border-right: none;
+ justify-content: flex-end;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.stats-table .table-cell:nth-child(2) {
+ justify-content: flex-end;
+ color: rgba(139, 92, 246, 0.9);
+ font-weight: 500;
+}
+
+.stats-table .table-cell:first-child {
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: rgba(255, 255, 255, 0.95);
+}
+
+/* Specific column styling for different metrics */
+.stats-table .col-key .table-cell:first-child {
+ color: var(--accent-primary);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 13px;
+}
+
+.stats-table .col-date .table-cell:first-child {
+ color: rgba(139, 92, 246, 0.9);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 13px;
+}
+
+/* Scrollbar styling for table body */
+.stats-table .table-body::-webkit-scrollbar {
+ width: 6px;
+}
+
+.stats-table .table-body::-webkit-scrollbar-track {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 3px;
+}
+
+.stats-table .table-body::-webkit-scrollbar-thumb {
+ background: rgba(139, 92, 246, 0.4);
+ border-radius: 3px;
+}
+
+.stats-table .table-body::-webkit-scrollbar-thumb:hover {
+ background: rgba(139, 92, 246, 0.6);
+}
+
+/* Empty state for tables */
+.stats-table .table-empty {
+ padding: 60px 20px;
+ text-align: center;
+ color: rgba(255, 255, 255, 0.5);
+ font-style: italic;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ min-height: 200px;
+}
+
+/* Responsive table styles */
+@media (max-width: 768px) {
+
+ .stats-table .table-header,
+ .stats-table .table-row {
+ grid-template-columns: 1fr 100px 100px;
+ }
+
+ .stats-table .header-cell,
+ .stats-table .table-cell {
+ padding: 12px 8px;
+ font-size: 12px;
+ }
+
+ .chart-container {
+ height: 300px;
+ }
+}
+
+@media (max-width: 480px) {
+
+ .stats-table .table-header,
+ .stats-table .table-row {
+ grid-template-columns: 1fr 80px 80px;
+ }
+
+ .stats-table .header-cell,
+ .stats-table .table-cell {
+ padding: 10px 8px;
+ font-size: 11px;
+ }
+}
+
+/* Interactive Chart Styles */
+.stats-chart {
+ transition: opacity 0.2s ease;
+}
+
+.stats-chart:hover {
+ opacity: 0.95;
+}
\ No newline at end of file
diff --git a/static/css/dashboard/url-modal.css b/static/css/dashboard/url-modal.css
new file mode 100644
index 00000000..5465745c
--- /dev/null
+++ b/static/css/dashboard/url-modal.css
@@ -0,0 +1,753 @@
+/* URL Management Modal Styles */
+
+/* Modal Base */
+.modal {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 9999;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1), visibility 0.4s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.modal.active {
+ display: flex;
+ opacity: 1;
+ visibility: visible;
+}
+
+/* Modal Container */
+.modal-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ padding: 20px;
+ position: relative;
+ z-index: 2;
+ background: rgba(0, 0, 0, 0.7);
+ -webkit-backdrop-filter: blur(10px) saturate(180%) brightness(0.7);
+ backdrop-filter: blur(10px) saturate(180%) brightness(0.7);
+}
+
+.modal-container.modal-sm {
+ margin: 0 auto;
+}
+
+/* Modal Content */
+.modal-content {
+ backdrop-filter: blur(60px);
+ background: rgba(15, 20, 35, 0.5);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 16px;
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
+ width: 100%;
+ max-width: 800px;
+ max-height: 90vh;
+ overflow: hidden;
+ transform: scale(0.85) translateY(20px);
+ opacity: 0;
+ transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.modal.active .modal-content {
+ transform: scale(1) translateY(0);
+ opacity: 1;
+}
+
+.modal-sm .modal-content {
+ max-width: 500px;
+}
+
+/* Modal Header */
+.modal-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ padding: 20px 21px 16px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.modal-title-section-delete, .modal-title-section-success {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.modal-icon {
+ width: 48px;
+ height: 48px;
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 24px;
+ margin: auto;
+ flex-shrink: 0;
+}
+
+.modal-danger .modal-icon {
+ background: linear-gradient(135deg, #ef4444, #dc2626);
+ color: white;
+}
+
+.modal-title {
+ font-size: 24px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 0 0 4px 0;
+ line-height: 1.2;
+}
+
+.modal-subtitle {
+ font-size: 14px;
+ color: var(--text-secondary);
+ margin: 0;
+ line-height: 1.4;
+}
+
+.modal-close {
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 24px;
+ cursor: pointer;
+ padding: 8px;
+ border-radius: 8px;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+}
+
+.modal-close:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: var(--text-primary);
+}
+
+/* Modal Body */
+.modal-body {
+ padding: 32px;
+ max-height: 60vh;
+ overflow-y: auto;
+}
+
+.modal-body::-webkit-scrollbar {
+ width: 6px;
+}
+
+.modal-body::-webkit-scrollbar-track {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 3px;
+}
+
+.modal-body::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 3px;
+}
+
+/* Tabs */
+.tabs {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 32px;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 12px;
+ padding: 4px;
+ position: relative;
+}
+
+.tabs::before {
+ content: '';
+ position: absolute;
+ top: 4px;
+ left: 4px;
+ width: calc(33.333% - 2.67px);
+ height: calc(100% - 8px);
+ background: rgba(255, 255, 255, 0.15);
+ border-radius: 8px;
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ z-index: 1;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.tabs[data-active="0"]::before {
+ transform: translateX(0);
+}
+
+.tabs[data-active="1"]::before {
+ transform: translateX(calc(100% + 4px));
+}
+
+.tabs[data-active="2"]::before {
+ transform: translateX(calc(200% - 1px));
+}
+
+.tab {
+ flex: 1;
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ padding: 12px 16px;
+ border-radius: 8px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ position: relative;
+ z-index: 2;
+}
+
+.tab:hover {
+ color: var(--text-primary);
+}
+
+.tab.active {
+ color: var(--text-primary);
+}
+
+.tab i {
+ font-size: 16px;
+}
+
+/* Tab Content */
+.tab-content {
+ display: none;
+ opacity: 0;
+ transform: translateY(8px);
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.tab-content.active {
+ display: block;
+ opacity: 1;
+ transform: translateY(0);
+ animation: tabFadeIn 0.25s cubic-bezier(0.4, 0, 0.2, 1) forwards;
+}
+
+@keyframes tabFadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Tab content container */
+.tab-content-container {
+ position: relative;
+ min-height: 250px;
+}
+
+/* Form Grid */
+.form-grid {
+ display: grid;
+ gap: 24px;
+}
+
+/* Fields */
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ width: 100%;
+ min-width: 0;
+}
+
+.field label {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text-primary);
+}
+
+.field input,
+.field select {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 8px;
+ padding: 12px 16px;
+ color: var(--text-primary);
+ font-size: 14px;
+ transition: all 0.2s ease;
+ width: 100%;
+ box-sizing: border-box;
+ min-width: 0;
+}
+
+.field input:focus,
+.field select:focus {
+ outline: none;
+ border-color: var(--accent-primary);
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.field input::placeholder {
+ color: var(--text-secondary);
+}
+
+.field input:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ background: rgba(255, 255, 255, 0.02);
+}
+
+/* Input Group */
+.input-group {
+ display: flex;
+ align-items: center;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 8px;
+ overflow: hidden;
+ transition: all 0.2s ease;
+ width: 100%;
+ box-sizing: border-box;
+ min-width: 0;
+}
+
+.input-group:focus-within {
+ border-color: var(--accent-color);
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
+}
+
+.input-prefix {
+ background: rgba(255, 255, 255, 0.1);
+ color: var(--text-secondary);
+ padding: 12px 16px;
+ font-size: 14px;
+ border-right: 1px solid rgba(255, 255, 255, 0.1);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.input-group input {
+ background: none;
+ border: none;
+ flex: 1;
+ min-width: 0;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+/* Select Wrapper */
+.select-wrapper {
+ position: relative;
+}
+
+.select-wrapper select {
+ appearance: none;
+ padding-right: 40px;
+ cursor: pointer;
+}
+
+.select-icon {
+ position: absolute;
+ right: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--text-secondary);
+ pointer-events: none;
+ font-size: 16px;
+}
+
+/* Field Hint */
+.field-hint {
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.4;
+}
+
+/* Password Field Group */
+.password-field-group {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.password-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.btn-link {
+ background: none;
+ border: none;
+ color: #ef4444;
+ font-size: 12px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 0;
+ transition: all 0.2s ease;
+}
+
+.btn-link:hover {
+ color: #dc2626;
+ text-decoration: underline;
+}
+
+.btn-link i {
+ font-size: 12px;
+}
+
+.password-status {
+ font-size: 12px;
+ color: var(--text-secondary);
+ font-style: italic;
+}
+
+/* Checkbox Field */
+.checkbox-field {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+}
+
+.checkbox-label {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ cursor: pointer;
+ flex: 1;
+}
+
+.checkbox-indicator {
+ width: 20px;
+ height: 20px;
+ border: 2px solid rgba(255, 255, 255, 0.2);
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.05);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+ flex-shrink: 0;
+ margin-top: 2px;
+}
+
+.checkbox-field input[type="checkbox"] {
+ display: none;
+}
+
+.checkbox-field input[type="checkbox"]:checked+.checkbox-label .checkbox-indicator {
+ background: var(--accent-color);
+ border-color: var(--accent-color);
+}
+
+.checkbox-field input[type="checkbox"]:checked+.checkbox-label .checkbox-indicator::after {
+ content: '✓';
+ color: white;
+ font-size: 12px;
+ font-weight: bold;
+}
+
+.checkbox-field input[type="checkbox"]:disabled+.checkbox-label {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.checkbox-field input[type="checkbox"]:disabled+.checkbox-label .checkbox-indicator {
+ background: rgba(255, 255, 255, 0.02);
+ border-color: rgba(255, 255, 255, 0.1);
+}
+
+.checkbox-content {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.checkbox-title {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text-primary);
+}
+
+.checkbox-desc {
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.4;
+}
+
+/* Modal Footer */
+.modal-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 24px 32px;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.footer-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+}
+
+.footer-actions-left {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.footer-actions-right {
+ display: flex;
+ align-items: center;
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 10px 16px;
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 500;
+ text-decoration: none;
+ border: 1px solid transparent;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ white-space: nowrap;
+ min-height: 36px;
+}
+
+.btn i {
+ font-size: 14px;
+}
+
+.btn-primary {
+ background: #6366f1;
+ color: white;
+ border-color: #6366f1;
+}
+
+.btn-primary:hover {
+ background: #5855eb;
+ border-color: #5855eb;
+}
+
+.btn-primary:active {
+ background: #4f46e5;
+ border-color: #4f46e5;
+}
+
+.btn-warning {
+ background: #f59e0b;
+ color: white;
+ border-color: #f59e0b;
+}
+
+.btn-warning:hover {
+ background: #d97706;
+ border-color: #d97706;
+}
+
+.btn-warning:active {
+ background: #b45309;
+ border-color: #b45309;
+}
+
+.btn-danger {
+ background: #ef4444;
+ color: white;
+ border-color: #ef4444;
+}
+
+.btn-danger:hover {
+ background: #dc2626;
+ border-color: #dc2626;
+}
+
+.btn-danger:active {
+ background: #b91c1c;
+ border-color: #b91c1c;
+}
+
+.btn-success {
+ background: #10b981;
+ color: white;
+ border-color: #10b981;
+}
+
+.btn-success:hover {
+ background: #059669;
+ border-color: #059669;
+}
+
+.btn-success:active {
+ background: #047857;
+ border-color: #047857;
+}
+
+.btn-secondary {
+ background: rgba(255, 255, 255, 0.1);
+ color: var(--text-secondary);
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+.btn-secondary:hover {
+ background: rgba(255, 255, 255, 0.15);
+ color: var(--text-primary);
+ border-color: rgba(255, 255, 255, 0.3);
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
+/* Clickable Rows */
+.clickable-row {
+ transition: all 0.2s ease;
+ border-radius: 8px;
+}
+
+.clickable-row:hover {
+ background: rgba(255, 255, 255, 0.05);
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.clickable-row:active {
+ transform: translateY(0);
+}
+
+/* Warning Content */
+.warning-content {
+ text-align: center;
+}
+
+.warning-content p {
+ color: var(--text-secondary);
+ margin: 0 0 16px 0;
+ line-height: 1.5;
+}
+
+.url-preview {
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid rgba(239, 68, 68, 0.2);
+ border-radius: 8px;
+ padding: 12px 16px;
+ margin: 16px 0;
+ font-family: 'Monaco', 'Menlo', monospace;
+}
+
+.url-preview strong {
+ color: #ef4444;
+ font-weight: 600;
+}
+
+/* Mobile Responsive */
+@media (max-width: 768px) {
+ .modal-container {
+ padding: 16px;
+ }
+
+ .modal-content {
+ max-width: none;
+ max-height: 95vh;
+ }
+
+ .modal-header,
+ .modal-body,
+ .modal-footer {
+ padding: 20px;
+ }
+
+ /* Fix input overflow on mobile */
+ .field input,
+ .field select {
+ width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+ }
+
+ /* Fix input group overflow */
+ .input-group {
+ width: 100%;
+ min-width: 0;
+ overflow: hidden;
+ box-sizing: border-box;
+ }
+
+ .input-group input {
+ min-width: 0;
+ width: 100%;
+ box-sizing: border-box;
+ }
+
+ .input-prefix {
+ flex-shrink: 0;
+ max-width: 50%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: 12px;
+ padding: 12px 12px;
+ }
+
+ /* Ensure form grid is responsive */
+ .form-grid {
+ width: 100%;
+ box-sizing: border-box;
+ }
+
+ /* Make field containers responsive */
+ .field {
+ width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+ }
+
+ .footer-actions {
+ flex-direction: column;
+ gap: 12px;
+ align-items: stretch;
+ }
+
+ .footer-actions-left,
+ .footer-actions-right {
+ width: 100%;
+ justify-content: center;
+ }
+
+ .footer-actions-left {
+ order: 2;
+ }
+
+ .footer-actions-right {
+ order: 1;
+ }
+
+ .footer-actions .btn {
+ width: 100%;
+ justify-content: center;
+ }
+
+ .tab {
+ justify-content: flex-start;
+ }
+}
\ No newline at end of file
diff --git a/static/css/header.css b/static/css/header.css
index c4e077d1..6b2c50fc 100644
--- a/static/css/header.css
+++ b/static/css/header.css
@@ -1,31 +1,32 @@
@import url('https://fonts.googleapis.com/css2?family=Allerta+Stencil&display=swap');
.navbar {
- background-color: rgba(255, 255, 255, 0.05);
- padding: 10px 16px;
+ background-color: rgba(255, 255, 255, 0.04);
+ padding: 6px 12px;
text-align: right;
- border-radius: 4px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
position: sticky;
- z-index: 9999999999999999999;
+ z-index: 9999;
top: 0;
backdrop-filter: blur(20px);
display: flex;
flex-direction: row;
font-family: 'Allerta Stencil', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
- transition: margin 0.2s cubic-bezier(0.8, 0, 0.2, 1);
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ align-items: center;
+ gap: 20px;
}
.navbar.scrolled {
position: fixed;
- width: 100%; /* Fallback for Firefox */
- width: -webkit-fill-available;
- margin: 10px;
- border-radius: 10px;
- box-shadow: rgba(0, 0, 0, 0.16) 0px 10px 36px 0px, rgba(0, 0, 0, 0.06) 0px 0px 0px 1px;
- box-shadow: rgba(0, 0, 0, 0.24) 0px 0px 4px 0px, rgba(0, 0, 0, 0.06) 0px 5px 20px 5px, rgba(0, 0, 0, 0.03) 0px 0px 0px 1px;
- border: 1px solid rgba(255, 255, 255, 0.18);
- backdrop-filter: saturate(1.5) blur(12px);
+ width: calc(100% - 40px);
+ margin: 12px 20px;
+ border-radius: 12px;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ backdrop-filter: saturate(1.4) blur(16px);
+ background-color: rgba(255, 255, 255, 0.06);
}
.navbar ul {
@@ -33,39 +34,203 @@
margin: 0;
padding: 0;
height: 100%;
- justify-content: space-around;
+ justify-content: flex-end;
display: flex;
align-items: center;
+ gap: 8px;
+ outline: none;
}
.navbar .links {
- padding: 0px 10px;
- width: 100%;
+ padding: 0;
width: -webkit-fill-available;
+ width: 100%;
}
-.navbar li {
- display: inline-block;
+.navbar li {
+ display: inline-flex;
+ align-items: center;
}
.navbar a {
- color: #fff;
+ color: rgba(255, 255, 255, 0.75);
text-decoration: none;
- font-size: 16px;
- padding: 5px;
- transition: all 0.1s ease-in;
- letter-spacing: 0.8px;
+ font-size: 15px;
+ padding: 8px 14px;
+ transition: all 0.2s ease;
+ letter-spacing: 0.5px;
+ border-radius: 6px;
}
.navbar a:hover {
text-decoration: none;
- text-shadow: 0 0 5px #fff, 0 0 15px #fff, 0 0 20px #fff, 0 0 40px #fff, 0 0 25px #fff, 0 0 1px #fff, 0 0 2px #fff;
+ color: rgba(255, 255, 255, 0.95);
}
.navbar a.active {
- font-weight: bold;
+ color: #fff;
+}
+
+.navbar-logo-link {
+ display: flex;
+ align-items: center;
+ text-decoration: none;
+ transition: opacity 0.2s ease;
+}
+
+.navbar-logo-link:hover {
+ opacity: 0.85;
+}
+
+.navbar-logo-link:focus {
+ outline: 2px solid rgba(255, 255, 255, 0.3);
+ outline-offset: 4px;
+ border-radius: 4px;
}
.navbar-image {
- height: 25px;
+ height: 32px;
+}
+
+/* Navigation groups */
+.nav-group-primary,
+.nav-group-secondary,
+.nav-group-auth {
+ display: inline-flex;
+ align-items: center;
+}
+
+/* Divider */
+.nav-divider {
+ width: 1px;
+ height: 20px;
+ background-color: rgba(255, 255, 255, 0.15);
+ margin: 0 8px;
+}
+
+/* External link icon */
+.external-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.external-link svg {
+ opacity: 0.7;
+ transition: all 0.2s ease;
+}
+
+.external-link:hover svg {
+ opacity: 1;
+ transform: translate(1px, -1px);
+}
+
+/* GitHub badge */
+.github-badge {
+ display: inline-flex !important;
+ align-items: center;
+ gap: 0;
+ padding: 0 !important;
+ background: rgba(255, 255, 255, 0.08) !important;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 8px;
+ overflow: hidden;
+ transition: all 0.2s ease;
+}
+
+.github-badge:hover {
+ background: rgba(255, 255, 255, 0.12) !important;
+ border-color: rgba(255, 255, 255, 0.25);
+ transform: translateY(-1px);
+}
+
+.github-icon {
+ display: flex;
+ align-items: center;
+ padding: 6px 10px;
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.github-icon svg {
+ width: 20px;
+ height: 20px;
+ display: block;
+}
+
+.github-divider {
+ width: 1px;
+ height: 24px;
+ background: rgba(255, 255, 255, 0.15);
+}
+
+.github-stars {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ font-size: 14px;
+ font-weight: 500;
+ color: #fff;
+}
+
+.star-icon {
+ width: 14px;
+ height: 14px;
+ color: #fbbf24;
+}
+
+/* Sign in button */
+.nav-signin-btn {
+ background: rgba(255, 255, 255, 0.08) !important;
+ border: 1px solid rgba(255, 255, 255, 0.15) !important;
+ color: #fff !important;
+ padding: 7px 16px !important;
+ border-radius: 8px;
+ font-weight: 500;
+ transition: all 0.2s ease;
+}
+
+.nav-signin-btn:hover {
+ background: rgba(255, 255, 255, 0.12) !important;
+ border-color: rgba(255, 255, 255, 0.25) !important;
+ transform: translateY(-1px);
+}
+
+/* Profile menu */
+.nav-profile { position: relative; display: none; }
+.profile-btn { border: 0; background: transparent; padding: 0; cursor: pointer; line-height: 0; border-radius: 9999px; }
+.profile-btn:hover, .profile-btn:focus { outline: none; box-shadow: none; }
+.profile-avatar-container { width: 36px; height: 36px; position: relative; }
+.profile-btn img { width: 36px; height: 36px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.2); display: block; }
+.profile-btn:hover img { filter: brightness(1.05); }
+.profile-initials-circle {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #7c3aed, #2563eb);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+ font-size: 14px;
+ color: white;
+ border: 1px solid rgba(255,255,255,0.2);
+}
+.profile-btn:hover .profile-initials-circle { filter: brightness(1.05); }
+.profile-dropdown {
+ position: absolute;
+ right: 0;
+ top: calc(100% + 8px);
+ min-width: 180px;
+ background: rgba(13, 17, 35, 0.96);
+ border: 1px solid rgba(255,255,255,0.12);
+ border-radius: 10px;
+ padding: 8px;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.35);
+ backdrop-filter: blur(12px);
+ z-index: 1000;
}
+.profile-dropdown a,
+.profile-dropdown button { display: block; width: 100%; text-align: left; color: #fff; text-decoration: none; padding: 8px 10px; background: transparent; border: 0; border-radius: 8px; font-size: 14px; cursor: pointer; text-shadow: none; }
+.profile-dropdown a:hover,
+.profile-dropdown button:hover { background: rgba(255,255,255,0.08); text-shadow: none !important; width: -webkit-fill-available; }
\ No newline at end of file
diff --git a/static/css/index.css b/static/css/index.css
index f19a4aa6..ae89c9ed 100644
--- a/static/css/index.css
+++ b/static/css/index.css
@@ -47,13 +47,14 @@ label[for=max-clicks] {
}
.alias-time-container .child {
- width: 100%; /* FireFox Fallback */
- width: -webkit-fill-available;
+ width: -webkit-fill-available; /* FireFox Fallback */
+ width: 100%;
}
input[type="text"],
input[type="password"],
-input[type="number"] {
+input[type="number"],
+input[type="email"] {
width: 100%;
padding: 15px;
border-radius: 20px;
@@ -397,25 +398,6 @@ a:hover {
color: #f9e2af;
}
-
-#producthunt {
- position: fixed;
- z-index: 888888;
- text-align: left;
- padding: 20px;
- left: 0;
- bottom: 0;
- transition: all 0.3s ease-in-out;
-}
-
-#producthunt img {
- transition: all 0.3s ease-in-out;
-}
-
-#producthunt img:hover {
- transform: scale(1.1);
-}
-
.checkbox-wrapper-4 * {
box-sizing: border-box;
}
@@ -506,6 +488,7 @@ label.cbx {
width: 0;
height: 0;
pointer-events: none;
+ -webkit-user-select: none;
user-select: none;
}
@@ -545,10 +528,6 @@ label.cbx {
margin-left: 6%;
margin-right: 6%;
}
-
- #producthunt img:hover {
- transform: none;
- }
}
@media screen and (max-width: 900px) {
@@ -601,10 +580,6 @@ label.cbx {
.metrics h4 {
font-size: 15px;
}
-
- #producthunt {
- display: none;
- }
}
@media screen and (max-width: 750px) {
diff --git a/static/css/mobile-header.css b/static/css/mobile-header.css
index 3fb30b18..fdfd3c1a 100644
--- a/static/css/mobile-header.css
+++ b/static/css/mobile-header.css
@@ -1,115 +1,272 @@
@import url('https://fonts.googleapis.com/css2?family=Allerta+Stencil&display=swap');
.mobile-navbar {
- background: rgba(255, 255, 255, 0.05);
- border-radius: 4px;
+ background: rgba(255, 255, 255, 0.04);
+ border-radius: 0;
position: sticky;
- z-index: 9999999999999999999;
+ z-index: 9999;
top: 0;
left: 0;
right: 0;
- box-shadow: 0px 1px 6px rgba(0, 0, 0, 0.2);
width: 100%;
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
color: white;
margin: 0;
font-family: "Allerta Stencil", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
- transition: margin 0.2s cubic-bezier(0.8, 0, 0.2, 1);
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.mobile-navbar.scrolled {
position: fixed;
- width: -webkit-fill-available;
- margin: 10px;
- border-radius: 10px;
- box-shadow: rgba(0, 0, 0, 0.16) 0px 10px 36px 0px, rgba(0, 0, 0, 0.06) 0px 0px 0px 1px;
- box-shadow: rgba(0, 0, 0, 0.24) 0px 0px 4px 0px, rgba(0, 0, 0, 0.06) 0px 5px 20px 5px, rgba(0, 0, 0, 0.03) 0px 0px 0px 1px;
- border: 1px solid rgba(255, 255, 255, 0.18);
- backdrop-filter: saturate(1.5) blur(12px);
+ width: calc(100% - 24px);
+ margin: 12px;
+ border-radius: 12px;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ backdrop-filter: saturate(1.4) blur(16px);
+ background-color: rgba(255, 255, 255, 0.06);
}
+.mobile-header-content {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ position: relative;
+}
.mobile-logo {
- padding: 9px 5px 6px 35px;
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ padding: 0;
+}
+
+.mobile-logo a {
+ display: flex;
+ align-items: center;
+ text-decoration: none;
+ transition: opacity 0.2s ease;
+}
+
+.mobile-logo a:hover {
+ opacity: 0.85;
}
.mobile-logo-image {
- height: 25px;
+ height: 28px;
}
-.mobile-logo a {
+/* Mobile right section */
+.mobile-right-section {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* Mobile sign in button */
+.mobile-auth-btn {
+ display: flex;
+ align-items: center;
+}
+
+.mobile-signin-btn {
+ display: inline-block;
+ padding: 6px 14px;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 8px;
+ color: #fff;
text-decoration: none;
- color: #333;
- font-size: 1em;
+ font-size: 14px;
+ font-weight: 500;
+ transition: all 0.2s ease;
}
+.mobile-signin-btn:hover {
+ background: rgba(255, 255, 255, 0.12);
+ border-color: rgba(255, 255, 255, 0.25);
+ text-decoration: none !important;
+}
+
+/* Burger icon */
button.burger {
- position: absolute;
- left: 0;
- top: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 4px;
border: 0;
background: transparent;
- padding: 8px 15px;
+ padding: 8px;
cursor: pointer;
outline: none;
+ width: 40px;
+ height: 40px;
}
button.burger span {
display: block;
- width: 4px;
- height: 4px;
- background: #fff;
- margin: 3px 0;
- border-radius: 4px;
- transition: all .4s ease-in-out;
+ width: 20px;
+ height: 2px;
+ background: rgba(255, 255, 255, 0.85);
+ border-radius: 2px;
+ transition: all 0.3s ease;
}
-button.burger:focus span:first-child {
- transform-origin: 2px 9px;
- transform: rotate(-90deg);
+button.burger[aria-expanded="true"] span:first-child {
+ transform: translateY(6px) rotate(45deg);
}
-button.burger:focus span:last-child {
- transform-origin: 2px -5px;
- transform: rotate(-90deg);
+button.burger[aria-expanded="true"] span:nth-child(2) {
+ opacity: 0;
}
-button.burger:focus+ul {
- max-height: 500px;
+button.burger[aria-expanded="true"] span:last-child {
+ transform: translateY(-6px) rotate(-45deg);
}
+/* Mobile menu */
ul.mobile-menu {
list-style: none;
padding: 0;
margin: 0;
overflow: hidden;
max-height: 0;
- transition: all 0.6s ease-in-out;
+ transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1);
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.mobile-navbar.menu-open ul.mobile-menu {
+ max-height: 600px;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
}
-ul.mobile-menu>li {
+ul.mobile-menu > li {
display: block;
- padding: 10px 15px 12px;
- transition: all 0.2s ease;
+ padding: 0;
+ transition: background 0.2s ease;
}
-ul.mobile-menu>li>a {
+ul.mobile-menu > li > a {
display: block;
text-decoration: none;
- color: white;
+ color: rgba(255, 255, 255, 0.75);
+ padding: 14px 20px;
+ font-size: 15px;
+ letter-spacing: 0.5px;
+ transition: all 0.2s ease;
+}
+
+ul.mobile-menu > li > a.active {
+ color: #fff;
+ background: rgba(255, 255, 255, 0.05);
}
-ul.mobile-menu>li>a:hover {
+ul.mobile-menu > li > a:hover {
text-decoration: none;
+ color: #fff;
}
-ul.mobile-menu>li:hover {
- /* background: linear-gradient(141deg, #48ded4 0%, #a026bf 51%, #e82c75 75%);*/
- background: rgba(255, 255, 255, 0.125);
+ul.mobile-menu > li:hover {
+ background: rgba(255, 255, 255, 0.08);
cursor: pointer;
}
+.menu-divider {
+ height: 1px;
+ background: rgba(255, 255, 255, 0.08);
+ margin: 8px 20px;
+}
+
+.external-link {
+ display: flex !important;
+ align-items: center;
+ gap: 6px;
+}
+
+.external-link svg {
+ opacity: 0.6;
+ height: 14px !important;
+ width: 14px !important;
+}
+
+.menu-signin {
+ color: #fff !important;
+ font-weight: 500;
+}
+
+/* Mobile profile menu */
+.mobile-profile {
+ display: flex;
+ align-items: center;
+}
+.mobile-profile .profile-btn {
+ border: 0;
+ background: transparent;
+ padding: 0;
+ line-height: 0;
+ border-radius: 50%;
+ cursor: pointer;
+}
+.mobile-profile .profile-avatar-container {
+ width: 36px;
+ height: 36px;
+ position: relative;
+}
+.mobile-profile .profile-btn img {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ border: 1px solid rgba(255,255,255,0.2);
+ display: block;
+}
+.mobile-profile .profile-initials-circle {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #7c3aed, #2563eb);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+ font-size: 14px;
+ color: white;
+ border: 1px solid rgba(255,255,255,0.2);
+}
+.mobile-profile .profile-dropdown {
+ position: absolute;
+ right: 0;
+ top: calc(100% + 8px);
+ min-width: 180px;
+ background: rgba(13,17,35,0.96);
+ border: 1px solid rgba(255,255,255,0.12);
+ border-radius: 10px;
+ padding: 8px;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.35);
+ backdrop-filter: blur(12px);
+ z-index: 1000;
+}
+.mobile-profile .profile-dropdown a,
+.mobile-profile .profile-dropdown button {
+ display: block;
+ width: 100%;
+ text-align: left;
+ color: #fff;
+ text-decoration: none;
+ padding: 8px 10px;
+ background: transparent;
+ border: 0;
+ border-radius: 8px;
+ font-size: 14px;
+ cursor: pointer;
+}
+.mobile-profile .profile-dropdown a:hover,
+.mobile-profile .profile-dropdown button:hover {
+ background: rgba(255,255,255,0.08);
+}
+
@media screen and (min-width: 768px) {
.mobile-navbar {
diff --git a/static/css/v2-announcement.css b/static/css/v2-announcement.css
new file mode 100644
index 00000000..d9c34531
--- /dev/null
+++ b/static/css/v2-announcement.css
@@ -0,0 +1,637 @@
+@import url('https://fonts.googleapis.com/css2?family=Nata+Sans:wght@400;500;600;700&display=swap');
+
+:root {
+ --v2-bg: #050713;
+ --v2-overlay: radial-gradient(circle at top right, rgba(124, 58, 237, 0.25), transparent 40%),
+ radial-gradient(circle at bottom left, rgba(37, 99, 235, 0.25), transparent 45%),
+ rgba(2, 3, 10, 0.9);
+ --v2-card: rgba(11, 16, 31, 0.92);
+ --v2-card-border: rgba(255, 255, 255, 0.08);
+ --v2-card-highlight: rgba(124, 58, 237, 0.3);
+ --v2-text: #f5f7ff;
+ --v2-muted: rgba(245, 247, 255, 0.72);
+ --v2-accent: #7c3aed;
+ --v2-accent-2: #6366f1;
+ --v2-accent-3: #a855f7;
+ --v2-button-shadow: 0 12px 35px rgba(99, 102, 241, 0.45);
+}
+
+/* V2 Announcement Badge */
+.v2-badge {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ z-index: 9999;
+ /* subtle glassy pill that matches modal tones */
+ background: linear-gradient(180deg, rgba(124,58,237,0.14), rgba(37,99,235,0.10));
+ color: var(--v2-text);
+ padding: 16px 30px;
+ border-radius: 999px;
+ font-weight: 700;
+ font-size: 17px;
+ cursor: pointer;
+ box-shadow: 0 8px 30px rgba(6, 4, 20, 0.55);
+ transition: transform 0.18s ease, box-shadow 0.18s ease, opacity 0.18s ease;
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ font-family: 'Nata Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ backdrop-filter: blur(6px) saturate(120%);
+ -webkit-backdrop-filter: blur(6px) saturate(120%);
+ overflow: hidden;
+}
+
+/* shimmer border + periodic shine */
+
+.v2-badge * {
+ position: relative;
+ z-index: 3;
+}
+
+/* MagicUI-like shimmer border + diagonal sheen */
+.v2-badge::before {
+ /* animated border band using mask to show only the outer rim */
+ content: '';
+ position: absolute;
+ inset: -3px; /* slightly outside the pill */
+ border-radius: inherit;
+ padding: 3px; /* thickness of the visible border */
+ background: linear-gradient(90deg, rgba(255,255,255,0.06), rgba(124,58,237,0.9), rgba(99,102,241,0.9), rgba(255,255,255,0.06));
+ background-size: 200% 100%;
+ background-position: 0% 50%;
+ pointer-events: none;
+ z-index: 1;
+ /* show only the band by using mask with content-box */
+ -webkit-mask: linear-gradient(#fff,#fff) content-box, linear-gradient(#fff,#fff);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ mask: linear-gradient(#fff,#fff) content-box, linear-gradient(#fff,#fff);
+ animation: borderShift 2.8s linear infinite;
+ filter: blur(6px);
+ opacity: 0.95;
+}
+
+.v2-badge::after {
+ /* diagonal sheen sweep across the pill */
+ content: '';
+ position: absolute;
+ left: -60%;
+ top: -30%;
+ width: 60%;
+ height: 160%;
+ background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.18) 50%, transparent 100%);
+ transform: skewX(-22deg) translateX(-150%);
+ pointer-events: none;
+ opacity: 0;
+ z-index: 4;
+ animation: sheen 3.8s cubic-bezier(.2,.9,.2,1) infinite;
+ animation-delay: 0.6s;
+}
+
+@keyframes borderShift {
+ 0% { background-position: 0% 50%; }
+ 50% { background-position: 100% 50%; }
+ 100% { background-position: 200% 50%; }
+}
+
+@keyframes sheen {
+ 0% { transform: skewX(-22deg) translateX(-150%); opacity: 0; }
+ 10% { opacity: 0.9; }
+ 45% { transform: skewX(-22deg) translateX(30%); opacity: 0.9; }
+ 70% { opacity: 0.35; }
+ 100% { transform: skewX(-22deg) translateX(200%); opacity: 0; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .v2-badge::before,
+ .v2-badge::after {
+ animation: none !important;
+ opacity: 0.06;
+ filter: none;
+ }
+}
+
+@keyframes shine {
+ 0% { transform: skewX(-22deg) translateX(-150%); opacity: 0; }
+ 8% { opacity: 0.9; }
+ 40% { transform: skewX(-22deg) translateX(40%); opacity: 0.9; }
+ 60% { opacity: 0.4; }
+ 100% { transform: skewX(-22deg) translateX(200%); opacity: 0; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .v2-badge::before,
+ .v2-badge::after {
+ animation: none !important;
+ opacity: 0.06;
+ }
+}
+
+.v2-badge:hover {
+ transform: translateY(-3px);
+ box-shadow: 0 14px 34px rgba(6, 4, 20, 0.6);
+}
+
+.v2-badge-icon {
+ font-size: 16px;
+ line-height: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* Overlay */
+.v2-modal-overlay {
+ display: none;
+ position: fixed;
+ inset: 0;
+ z-index: 10000;
+ background: var(--v2-overlay);
+ backdrop-filter: blur(16px) saturate(140%);
+ padding: clamp(16px, 4vw, 40px);
+ overflow-y: auto;
+ animation: overlayFadeIn 0.35s ease;
+}
+
+.v2-modal-overlay.active {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+@keyframes overlayFadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+/* Modal */
+.v2-modal {
+ width: min(560px, 100%);
+ border-radius: 16px;
+ border: 1px solid var(--v2-card-border);
+ background: var(--v2-card);
+ box-shadow: 0 40px 120px rgba(0, 0, 0, 0.65), 0 0 0 1px rgba(255, 255, 255, 0.03);
+ position: relative;
+ overflow: visible;
+ font-family: 'Nata Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ padding: 64px clamp(32px, 6vw, 64px) 40px;
+ animation: modalSlideUp 0.45s ease;
+}
+
+.v2-modal::before {
+ content: '';
+ position: absolute;
+ inset: -120px;
+ background: radial-gradient(circle, rgba(124, 58, 237, 0.12), transparent 60%);
+ z-index: 0;
+}
+
+/* allow clicks to pass through the decorative backdrop so overlay can receive clicks */
+.v2-modal::before,
+.v2-modal::after {
+ pointer-events: none;
+}
+
+@media (max-width: 720px) {
+ .v2-modal::before {
+ /* reduce the oversized pseudo on small screens so it doesn't cover the viewport */
+ inset: -40px;
+ }
+}
+
+.v2-modal > * {
+ position: relative;
+ z-index: 1;
+}
+
+.v2-modal-content-wrapper {
+ min-height: 460px;
+}
+
+@keyframes modalSlideUp {
+ from {
+ opacity: 0;
+ transform: translateY(40px) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+/* Close button removed (overlay click closes modal) - styles intentionally removed */
+
+/* Navigation arrows */
+.v2-nav-arrow {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--v2-text);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
+}
+
+.v2-nav-arrow:hover {
+ background: linear-gradient(135deg, rgba(124, 58, 237, 0.95), rgba(37, 99, 235, 0.95));
+ border-color: rgba(255, 255, 255, 0.2);
+ color: #fff;
+}
+
+.v2-prev-btn {
+ left: -72px;
+}
+
+.v2-next-btn.v2-nav-arrow {
+ right: -72px;
+}
+
+/* Content states */
+.v2-modal-content {
+ display: none;
+ text-align: center;
+ color: var(--v2-text);
+ padding-top: 16px;
+ animation: contentFadeIn 0.4s ease;
+ min-height: 420px;
+}
+
+.v2-modal-content.active {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 18px;
+ padding-bottom: 30px;
+ padding-top: 0;
+}
+
+@keyframes contentFadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Emoji & icons */
+.v2-emoji-container,
+.v2-feature-icon-large {
+ width: 108px;
+ height: 108px;
+ border-radius: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(135deg, rgba(124, 58, 237, 0.18), rgba(37, 99, 235, 0.18));
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 25px 50px rgba(15, 8, 38, 0.55);
+ /* margin-bottom: 18px; */
+}
+
+.v2-emoji-plain {
+ /* fully remove any background/shadow for plain emoji instances */
+ background: none !important;
+ box-shadow: none !important;
+ width: auto !important;
+ height: auto !important;
+ padding: 0 !important;
+ margin-bottom: 12px !important;
+ border-radius: 0 !important;
+}
+
+.v2-emoji-plain img {
+ filter: none !important;
+ width: 150px !important;
+}
+
+.v2-emoji-container img {
+ width: 100%;
+ height: auto;
+ display: block;
+ filter: drop-shadow(0 18px 30px rgba(10, 7, 26, 0.45));
+}
+
+/* feature slide emojis: same style as welcome, slightly smaller */
+.v2-emoji-feature {
+ background: none !important;
+ box-shadow: none !important;
+ width: auto !important;
+ height: auto !important;
+ padding: 0 !important;
+ border-radius: 0 !important;
+ margin-bottom: 10px;
+}
+
+.v2-emoji-feature img {
+ width: 110px;
+ height: auto;
+ filter: none;
+}
+
+.v2-emoji-container {
+ animation: emojiFloat 2.5s ease-in-out infinite;
+}
+
+/* Preview tooltip for feature slides */
+.v2-preview-tooltip {
+ position: absolute;
+ right: -18px;
+ top: -12px;
+ width: 220px;
+ height: 130px;
+ background-size: cover;
+ background-position: center;
+ border-radius: 12px;
+ box-shadow: 0 18px 40px rgba(6, 4, 20, 0.6);
+ border: 1px solid rgba(255,255,255,0.06);
+ opacity: 0;
+ transform: translateY(8px) scale(0.98);
+ transition: opacity 180ms ease, transform 180ms ease;
+ pointer-events: none;
+ z-index: 10002;
+}
+
+.v2-preview-tooltip.visible {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+}
+
+/* Hide previews on small screens */
+@media (max-width: 720px) {
+ .v2-preview-tooltip { display: none; }
+}
+
+/* Global floating tooltip (positioned via JS next to each bullet) */
+
+.v2-preview-tooltip-global {
+ position: fixed;
+ width: 320px;
+ height: 190px;
+ background-size: cover;
+ background-position: center;
+ border-radius: 12px;
+ box-shadow: 0 28px 70px rgba(6,4,20,0.75);
+ border: 1px solid rgba(255,255,255,0.06);
+ opacity: 0;
+ transform: translateY(8px) scale(0.98);
+ transition: opacity 220ms cubic-bezier(.2,.9,.2,1), transform 220ms cubic-bezier(.2,.9,.2,1);
+ pointer-events: none;
+ z-index: 12000;
+}
+.v2-preview-tooltip-global.visible {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+}
+
+@media (max-width: 900px) {
+ .v2-preview-tooltip-global { display: none; }
+}
+
+.v2-feature-icon-large {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+@keyframes emojiFloat {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-8px);
+ }
+}
+
+.v2-feature-icon-large i,
+.v2-feature-highlight-item i {
+ color: #b197fc;
+ font-size: 26px;
+}
+
+.v2-title {
+ font-size: clamp(32px, 4vw, 44px);
+ font-weight: 700;
+ margin: 0;
+ background: linear-gradient(120deg, var(--v2-accent), var(--v2-accent-2), var(--v2-accent-3));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+}
+
+.v2-subtitle {
+ margin: 0 auto;
+ max-width: 460px;
+ color: var(--v2-muted);
+ line-height: 1.6;
+ font-size: 17px;
+}
+
+.v2-feature-title {
+ font-size: 30px;
+ margin-bottom: 8px;
+ font-weight: 600;
+}
+
+.v2-feature-description {
+ color: var(--v2-muted);
+ margin: 0 auto 12px;
+ line-height: 1.55;
+ max-width: 420px;
+}
+
+.v2-feature-page {
+ width: 100%;
+ max-width: 440px;
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: 12px;
+}
+
+.v2-feature-highlights {
+ width: 100%;
+ max-width: 420px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ text-align: left;
+}
+
+.v2-feature-highlight-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 18px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 16px;
+ color: var(--v2-text);
+ font-size: 15px;
+ transition: transform 0.2s ease, border 0.2s ease;
+}
+
+.v2-feature-highlight-item:hover {
+ border-color: rgba(124, 58, 237, 0.4);
+ transform: translateX(6px);
+}
+
+/* Buttons */
+.v2-button {
+ padding: 16px 34px;
+ border-radius: 999px;
+ border: none;
+ font-size: 15px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: transform 0.18s ease, box-shadow 0.18s ease;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ color: #fff;
+ background: linear-gradient(135deg, var(--v2-accent-2), var(--v2-accent));
+ box-shadow: var(--v2-button-shadow);
+}
+
+.v2-button:hover {
+ transform: translateY(-3px);
+}
+
+.v2-button-ghost {
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--v2-muted);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ box-shadow: none;
+ padding: 12px 22px;
+}
+
+.v2-button-ghost:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: #fff;
+}
+
+/* Footer progress */
+.v2-modal-footer {
+ margin-top: 20px;
+ display: flex;
+ justify-content: center;
+}
+
+.v2-progress-dots {
+ display: inline-flex;
+ gap: 12px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ padding: 10px 18px;
+ border-radius: 999px;
+}
+
+.v2-progress-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.22);
+ transition: all 0.25s ease;
+ cursor: pointer;
+}
+
+/* Progress fill element that animates inside the active dot */
+.v2-progress-dot {
+ position: relative;
+ overflow: hidden;
+}
+
+.v2-progress-fill {
+ position: absolute;
+ left: 0;
+ top: 0;
+ height: 100%;
+ width: 0%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, rgba(124,58,237,1), rgba(99,102,241,1));
+ box-shadow: 0 6px 14px rgba(124,58,237,0.18);
+ pointer-events: none;
+ z-index: 0;
+ transition: width 15000ms linear;
+}
+
+.v2-progress-dot.active {
+ width: 36px;
+ background: rgba(255,255,255,0.04);
+ border: 1px solid rgba(255,255,255,0.06);
+ box-shadow: 0 6px 14px rgba(6,4,20,0.45), 0 2px 6px rgba(124,58,237,0.06) inset;
+}
+
+.v2-progress-dot:hover {
+ opacity: 0.85;
+}
+
+/* Responsive tweaks */
+@media (max-width: 900px) {
+ .v2-prev-btn {
+ display: none;
+ }
+
+ .v2-next-btn.v2-nav-arrow {
+ display: none;
+ }
+}
+
+@media (max-width: 720px) {
+ .v2-modal {
+ padding: 56px 28px 72px;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+ align-items: center;
+ }
+
+ .v2-modal-content-wrapper,
+ .v2-modal-footer {
+ grid-column: 1 / -1;
+ }
+
+ .v2-nav-arrow {
+ position: relative;
+ transform: none;
+ margin: 0 auto;
+ width: 48px;
+ height: 48px;
+ }
+}
+
+@media (max-width: 480px) {
+ .v2-badge {
+ bottom: 16px;
+ right: 16px;
+ }
+
+ .v2-modal {
+ border-radius: 26px;
+ }
+
+ .v2-button {
+ width: 100%;
+ }
+
+ .v2-feature-highlight-item {
+ font-size: 14px;
+ }
+}
diff --git a/static/images/Lightning.png b/static/images/Lightning.png
new file mode 100644
index 00000000..8d160d24
Binary files /dev/null and b/static/images/Lightning.png differ
diff --git a/static/images/Links _ Dashboard _ spoo_me.jpeg b/static/images/Links _ Dashboard _ spoo_me.jpeg
new file mode 100644
index 00000000..0159cbfb
Binary files /dev/null and b/static/images/Links _ Dashboard _ spoo_me.jpeg differ
diff --git a/static/images/Redo.png b/static/images/Redo.png
new file mode 100644
index 00000000..54d0639b
Binary files /dev/null and b/static/images/Redo.png differ
diff --git a/static/images/Rocket.png b/static/images/Rocket.png
new file mode 100644
index 00000000..ed60b848
Binary files /dev/null and b/static/images/Rocket.png differ
diff --git a/static/images/Shield.png b/static/images/Shield.png
new file mode 100644
index 00000000..6d107326
Binary files /dev/null and b/static/images/Shield.png differ
diff --git a/static/images/api_3D.png b/static/images/api_3D.png
new file mode 100644
index 00000000..9ad7dd96
Binary files /dev/null and b/static/images/api_3D.png differ
diff --git a/static/images/api_demo.jpeg b/static/images/api_demo.jpeg
new file mode 100644
index 00000000..83919b36
Binary files /dev/null and b/static/images/api_demo.jpeg differ
diff --git a/static/images/api_key_api_demo.jpeg b/static/images/api_key_api_demo.jpeg
new file mode 100644
index 00000000..d34b2a47
Binary files /dev/null and b/static/images/api_key_api_demo.jpeg differ
diff --git a/static/images/api_keys_demo.jpeg b/static/images/api_keys_demo.jpeg
new file mode 100644
index 00000000..9c7de043
Binary files /dev/null and b/static/images/api_keys_demo.jpeg differ
diff --git a/static/images/api_permissions_demo.jpeg b/static/images/api_permissions_demo.jpeg
new file mode 100644
index 00000000..20daadb2
Binary files /dev/null and b/static/images/api_permissions_demo.jpeg differ
diff --git a/static/images/dashboard_demo.jpeg b/static/images/dashboard_demo.jpeg
new file mode 100644
index 00000000..9e3da4a6
Binary files /dev/null and b/static/images/dashboard_demo.jpeg differ
diff --git a/static/images/edit_url_demo.jpeg b/static/images/edit_url_demo.jpeg
new file mode 100644
index 00000000..7549fbb7
Binary files /dev/null and b/static/images/edit_url_demo.jpeg differ
diff --git a/static/images/favicon-old.png b/static/images/favicon-old.png
new file mode 100644
index 00000000..66daeae6
Binary files /dev/null and b/static/images/favicon-old.png differ
diff --git a/static/images/favicon.png b/static/images/favicon.png
index 66daeae6..6c58dafd 100644
Binary files a/static/images/favicon.png and b/static/images/favicon.png differ
diff --git a/static/images/geo_stats_demo.jpeg b/static/images/geo_stats_demo.jpeg
new file mode 100644
index 00000000..61afef4f
Binary files /dev/null and b/static/images/geo_stats_demo.jpeg differ
diff --git a/static/images/key_3D.png b/static/images/key_3D.png
new file mode 100644
index 00000000..804c3c15
Binary files /dev/null and b/static/images/key_3D.png differ
diff --git a/static/images/link_3D.png b/static/images/link_3D.png
new file mode 100644
index 00000000..b1cb2308
Binary files /dev/null and b/static/images/link_3D.png differ
diff --git a/static/images/login_3D.png b/static/images/login_3D.png
new file mode 100644
index 00000000..f9c7754d
Binary files /dev/null and b/static/images/login_3D.png differ
diff --git a/static/images/logo-black.png b/static/images/logo-black.png
new file mode 100644
index 00000000..768c0d8f
Binary files /dev/null and b/static/images/logo-black.png differ
diff --git a/static/images/logo-text-dark.png b/static/images/logo-text-dark.png
new file mode 100644
index 00000000..40248525
Binary files /dev/null and b/static/images/logo-text-dark.png differ
diff --git a/static/images/logo-text-light.png b/static/images/logo-text-light.png
new file mode 100644
index 00000000..a1a28750
Binary files /dev/null and b/static/images/logo-text-light.png differ
diff --git a/static/images/logo-white.png b/static/images/logo-white.png
new file mode 100644
index 00000000..5c7bb988
Binary files /dev/null and b/static/images/logo-white.png differ
diff --git a/static/images/max_clicks_demo.jpeg b/static/images/max_clicks_demo.jpeg
new file mode 100644
index 00000000..3fd0568c
Binary files /dev/null and b/static/images/max_clicks_demo.jpeg differ
diff --git a/static/images/party_popper_3D.png b/static/images/party_popper_3D.png
new file mode 100644
index 00000000..4ec55237
Binary files /dev/null and b/static/images/party_popper_3D.png differ
diff --git a/static/images/pause_demo.jpeg b/static/images/pause_demo.jpeg
new file mode 100644
index 00000000..de8ab4ea
Binary files /dev/null and b/static/images/pause_demo.jpeg differ
diff --git a/static/images/rocket_3D.png b/static/images/rocket_3D.png
new file mode 100644
index 00000000..57e14707
Binary files /dev/null and b/static/images/rocket_3D.png differ
diff --git a/static/images/signin_demo.jpeg b/static/images/signin_demo.jpeg
new file mode 100644
index 00000000..04e79f40
Binary files /dev/null and b/static/images/signin_demo.jpeg differ
diff --git a/static/images/stats_3D.png b/static/images/stats_3D.png
new file mode 100644
index 00000000..57f6fcc7
Binary files /dev/null and b/static/images/stats_3D.png differ
diff --git a/static/images/stats_api_demo.jpeg b/static/images/stats_api_demo.jpeg
new file mode 100644
index 00000000..6e4c4142
Binary files /dev/null and b/static/images/stats_api_demo.jpeg differ
diff --git a/static/images/stats_demo.jpeg b/static/images/stats_demo.jpeg
new file mode 100644
index 00000000..330ccdbf
Binary files /dev/null and b/static/images/stats_demo.jpeg differ
diff --git a/static/images/time_filter_demo.jpeg b/static/images/time_filter_demo.jpeg
new file mode 100644
index 00000000..4ac0719e
Binary files /dev/null and b/static/images/time_filter_demo.jpeg differ
diff --git a/static/js/auth.js b/static/js/auth.js
new file mode 100644
index 00000000..3b0241ff
--- /dev/null
+++ b/static/js/auth.js
@@ -0,0 +1,453 @@
+function showAuthError(message) {
+ const errorEl = document.getElementById('authError');
+ if (errorEl) {
+ errorEl.textContent = message;
+ errorEl.style.display = 'block';
+ }
+}
+
+function clearAuthError() {
+ const errorEl = document.getElementById('authError');
+ if (errorEl) {
+ errorEl.textContent = '';
+ errorEl.style.display = 'none';
+ }
+}
+
+// Password validation functions
+function validateAuthPassword(password) {
+ if (!password) {
+ return {
+ isValid: false,
+ missingRequirements: ["Password is required"],
+ strengthScore: 0
+ };
+ }
+
+ const missing = [];
+ let strengthScore = 0;
+
+ // Basic requirements - only award points if requirement is met
+ const hasMinLength = password.length >= 8;
+ const hasMaxLength = password.length <= 128;
+ const hasUppercase = /[A-Z]/.test(password);
+ const hasLowercase = /[a-z]/.test(password);
+ const hasNumber = /[0-9]/.test(password);
+ const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~`]/.test(password);
+ const hasSafeChars = /^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~`\s]+$/.test(password);
+
+ // Check requirements and add to missing if not met
+ if (!hasMinLength) {
+ missing.push("At least 8 characters");
+ } else {
+ strengthScore += 20; // Only award if requirement is met
+ }
+
+ if (!hasMaxLength) {
+ missing.push("Maximum 128 characters");
+ } else {
+ strengthScore += 5; // Small bonus for reasonable length
+ }
+
+ if (!hasUppercase) {
+ missing.push("At least one uppercase letter");
+ } else {
+ strengthScore += 15;
+ }
+
+ if (!hasLowercase) {
+ missing.push("At least one lowercase letter");
+ } else {
+ strengthScore += 15;
+ }
+
+ if (!hasNumber) {
+ missing.push("At least one number");
+ } else {
+ strengthScore += 15;
+ }
+
+ if (!hasSpecialChar) {
+ missing.push("At least one special character");
+ } else {
+ strengthScore += 15;
+ }
+
+ if (!hasSafeChars) {
+ missing.push("Contains invalid characters");
+ } else {
+ strengthScore += 5; // Small bonus for safe characters
+ }
+
+ // Additional strength bonuses (only if basic requirements are met)
+ if (hasMinLength && password.length >= 12) {
+ strengthScore += 5;
+ }
+ if (hasMinLength && password.length >= 16) {
+ strengthScore += 5;
+ }
+
+ // Penalties for weak patterns
+ if (/(.)\1{2,}/.test(password)) { // 3+ repeated characters
+ strengthScore -= 15;
+ }
+
+ if (/(012|123|234|345|456|567|678|789|890|abc|bcd|cde|def)/i.test(password)) {
+ strengthScore -= 20;
+ }
+
+ // Common weak passwords - heavy penalty
+ const weakPatterns = [
+ /password/i,
+ /123456/i,
+ /qwerty/i,
+ /admin/i,
+ /login/i,
+ /welcome/i
+ ];
+
+ for (const pattern of weakPatterns) {
+ if (pattern.test(password)) {
+ strengthScore -= 30;
+ break;
+ }
+ }
+
+ // Ensure score is between 0-100
+ strengthScore = Math.max(0, Math.min(100, strengthScore));
+
+ return {
+ isValid: missing.length === 0,
+ missingRequirements: missing,
+ strengthScore: strengthScore
+ };
+}
+
+function getStrengthLabel(score) {
+ if (score < 20) return "Very Weak";
+ if (score < 40) return "Weak";
+ if (score < 60) return "Fair";
+ if (score < 80) return "Good";
+ return "Strong";
+}
+
+function getStrengthColor(score) {
+ if (score < 20) return "#ef4444"; // red
+ if (score < 40) return "#f97316"; // orange
+ if (score < 60) return "#eab308"; // yellow
+ if (score < 80) return "#22c55e"; // green
+ return "#16a34a"; // dark green
+}
+
+function updatePasswordStrength(password) {
+ const strengthContainer = document.getElementById('passwordStrengthContainer');
+ const strengthBar = document.getElementById('passwordStrengthBar');
+ const strengthLabel = document.getElementById('passwordStrengthLabel');
+ const requirementsList = document.getElementById('passwordRequirements');
+
+ // Always hide if not in register mode
+ if (typeof authMode === 'undefined' || authMode !== 'register') {
+ if (strengthContainer) {
+ strengthContainer.style.display = 'none';
+ }
+ if (requirementsList) {
+ requirementsList.style.display = 'none';
+ }
+ return { isValid: false, missingRequirements: [], strengthScore: 0 };
+ }
+
+ // Hide strength indicator if password is empty
+ if (!password || password.length === 0) {
+ if (strengthContainer) {
+ strengthContainer.style.display = 'none';
+ }
+ if (requirementsList) {
+ requirementsList.style.display = 'none';
+ }
+ return { isValid: false, missingRequirements: ["Password is required"], strengthScore: 0 };
+ }
+
+ // Show strength indicator when there's input in register mode
+ if (strengthContainer) {
+ strengthContainer.style.display = 'block';
+ }
+ if (requirementsList) {
+ requirementsList.style.display = 'block';
+ }
+
+ const validation = validateAuthPassword(password);
+
+ // Ensure validation object has all required properties
+ if (!validation || typeof validation !== 'object') {
+ console.error('validateAuthPassword returned invalid object:', validation);
+ return { isValid: false, missingRequirements: [], strengthScore: 0 };
+ }
+
+ const strengthScore = validation.strengthScore || 0;
+
+ if (strengthBar) {
+ const color = getStrengthColor(strengthScore);
+ strengthBar.style.width = `${strengthScore}%`;
+ strengthBar.style.backgroundColor = color;
+ }
+
+ if (strengthLabel) {
+ const label = getStrengthLabel(strengthScore);
+ const color = getStrengthColor(strengthScore);
+ strengthLabel.textContent = label;
+ strengthLabel.style.color = color;
+ }
+
+ if (requirementsList) {
+ // Ensure validation object has the required properties
+ const missingReqs = (validation && validation.missingRequirements) ? validation.missingRequirements : [];
+
+ // Only show missing requirements
+ if (missingReqs.length > 0) {
+ requirementsList.innerHTML = missingReqs.map(req => {
+ return `✗ ${req} `;
+ }).join('');
+ requirementsList.style.display = 'block';
+ } else {
+ requirementsList.style.display = 'none';
+ }
+ }
+
+ return validation;
+}
+
+function showPasswordRequirements(missingRequirements) {
+ if (missingRequirements.length === 0) return;
+
+ const errorEl = document.getElementById('authError');
+ if (errorEl) {
+ const requirementsList = missingRequirements.map(req =>
+ `✗ ${req} `
+ ).join('');
+
+ errorEl.innerHTML = `
+
+
Password requirements not met:
+
+
+ `;
+ errorEl.style.display = 'block';
+ }
+}
+
+async function authFetch(input, init) {
+ const opts = init || {};
+ if (!opts.credentials) { opts.credentials = 'include'; }
+ let res = await fetch(input, opts);
+ if (res.status !== 401) { return res; }
+ try {
+ const refreshRes = await fetch('/auth/refresh', { method: 'POST', credentials: 'include' });
+ if (!refreshRes.ok) { return res; }
+ res = await fetch(input, opts);
+ return res;
+ } catch (e) {
+ return res;
+ }
+}
+
+async function submitAuth() {
+ const email = document.getElementById('authEmail').value.trim();
+ const password = document.getElementById('authPassword').value;
+ const user_name_input = document.getElementById('authUserName');
+ const user_name = user_name_input ? user_name_input.value.trim() : '';
+
+ const isRegister = (typeof authMode !== 'undefined' && authMode === 'register');
+
+ // Validate password on frontend for registration
+ if (isRegister) {
+ const validation = validateAuthPassword(password);
+ if (!validation.isValid) {
+ showPasswordRequirements(validation.missingRequirements);
+ return;
+ }
+ }
+
+ const url = isRegister ? '/auth/register' : '/auth/login';
+ const body = isRegister ? { email, password, user_name } : { email, password };
+
+ try {
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(body)
+ });
+ const data = await res.json().catch(() => ({}));
+
+ if (!res.ok) {
+ // Handle password validation errors from backend
+ if (data.missing_requirements && data.missing_requirements.length > 0) {
+ showPasswordRequirements(data.missing_requirements);
+ } else {
+ showAuthError((data && data.error) || 'Something went wrong');
+ }
+ return;
+ }
+
+ closeAuthModal();
+ window.location.href = '/dashboard';
+ } catch (e) {
+ showAuthError('Something went wrong');
+ }
+}
+
+async function logout() {
+ try {
+ const res = await fetch('/auth/logout', { method: 'POST', credentials: 'include' });
+ await updateAuthNav();
+ if (res.ok) { window.location.href = '/'; }
+ } catch (e) { await updateAuthNav(); }
+}
+
+async function updateAuthNav() {
+ try {
+ const res = await authFetch('/auth/me', { credentials: 'include' });
+ const loggedIn = res.ok;
+ let user = null;
+ if (loggedIn) {
+ const data = await res.json().catch(() => ({}));
+ user = data && data.user ? data.user : null;
+ }
+ const show = (id, visible, displayType = 'contents') => { const el = document.getElementById(id); if (el) { el.style.display = visible ? displayType : 'none'; } };
+ // Desktop
+ show('nav-auth', !loggedIn);
+ show('nav-profile', loggedIn);
+ // Hide old direct links when logged in (now in dropdown)
+ show('nav-dashboard', false);
+ show('nav-keys', false);
+ // Mobile
+ show('m-nav-auth-btn', !loggedIn, 'block');
+ show('m-nav-dashboard', loggedIn);
+ show('m-nav-keys', loggedIn);
+ show('m-nav-logout', loggedIn);
+ show('m-nav-profile', loggedIn, 'block');
+
+ if (user) {
+ const username = (user.user_name && String(user.user_name).trim()) || (user.email ? String(user.email).split('@')[0] : 'user');
+
+ // Calculate initials for fallback
+ const getInitials = (name) => {
+ const words = name.split(' ').filter(word => word.length > 0);
+ if (words.length >= 2) {
+ return (words[0][0] + words[words.length - 1][0]).toUpperCase();
+ }
+ return name.substring(0, 2).toUpperCase();
+ };
+
+ const initials = getInitials(username);
+
+ // Handle desktop navbar profile avatar
+ const profileContainer = document.querySelector('.navbar .profile-avatar-container');
+ if (profileContainer) {
+ const img = profileContainer.querySelector('img');
+ const initialsDiv = profileContainer.querySelector('#profileInitials');
+
+ if (img && initialsDiv) {
+ if (user.pfp && user.pfp.url) {
+ img.src = user.pfp.url;
+ img.alt = username;
+ img.style.display = 'block';
+ initialsDiv.style.display = 'none';
+
+ // Handle image load failure
+ img.onerror = function () {
+ img.style.display = 'none';
+ initialsDiv.style.display = 'flex';
+ initialsDiv.textContent = initials;
+ };
+ } else {
+ img.style.display = 'none';
+ initialsDiv.style.display = 'flex';
+ initialsDiv.textContent = initials;
+ }
+ }
+ }
+
+ // Handle mobile navbar profile avatar
+ const mobileProfileContainer = document.querySelector('.mobile-navbar .profile-avatar-container');
+ if (mobileProfileContainer) {
+ const mimg = mobileProfileContainer.querySelector('img');
+ const minitialsDiv = mobileProfileContainer.querySelector('#profileInitials');
+
+ if (mimg && minitialsDiv) {
+ if (user.pfp && user.pfp.url) {
+ mimg.src = user.pfp.url;
+ mimg.alt = username;
+ mimg.style.display = 'block';
+ minitialsDiv.style.display = 'none';
+
+ // Handle image load failure
+ mimg.onerror = function () {
+ mimg.style.display = 'none';
+ minitialsDiv.style.display = 'flex';
+ minitialsDiv.textContent = initials;
+ };
+ } else {
+ mimg.style.display = 'none';
+ minitialsDiv.style.display = 'flex';
+ minitialsDiv.textContent = initials;
+ }
+ }
+ }
+
+ // Legacy handling for old profile avatars (in case they still exist)
+ const img = document.getElementById('profileAvatar');
+ if (img) {
+ const avatarUrl = (user.pfp && user.pfp.url)
+ ? user.pfp.url
+ : `https://avatar.iran.liara.run/username?username=${encodeURIComponent(username)}`;
+ img.src = avatarUrl;
+ img.alt = username;
+ img.onerror = function () {
+ if (this.src !== `https://avatar.iran.liara.run/username?username=${encodeURIComponent(username)}`) {
+ this.src = `https://avatar.iran.liara.run/username?username=${encodeURIComponent(username)}`;
+ }
+ };
+ }
+
+ const mimg = document.getElementById('mProfileAvatar');
+ if (mimg) {
+ const avatarUrl = (user.pfp && user.pfp.url)
+ ? user.pfp.url
+ : `https://avatar.iran.liara.run/username?username=${encodeURIComponent(username)}`;
+ mimg.src = avatarUrl;
+ mimg.alt = username;
+ mimg.onerror = function () {
+ if (this.src !== `https://avatar.iran.liara.run/username?username=${encodeURIComponent(username)}`) {
+ this.src = `https://avatar.iran.liara.run/username?username=${encodeURIComponent(username)}`;
+ }
+ };
+ }
+ }
+ } catch (e) { /* default to logged out */ }
+}
+
+document.addEventListener('DOMContentLoaded', function () {
+ if (typeof updateAuthNav === 'function') {
+ updateAuthNav();
+ }
+
+ // Set up password input event listener
+ const passwordInput = document.getElementById('authPassword');
+ if (passwordInput) {
+ passwordInput.addEventListener('input', function () {
+ handlePasswordInput(this.value);
+ });
+ }
+});
+
+function handlePasswordInput(password) {
+ // Only show strength indicator in register mode
+ if (typeof authMode !== 'undefined' && authMode === 'register') {
+ updatePasswordStrength(password);
+ }
+}
+
+
diff --git a/static/js/dashboard.js b/static/js/dashboard.js
new file mode 100644
index 00000000..e8adfe78
--- /dev/null
+++ b/static/js/dashboard.js
@@ -0,0 +1,325 @@
+(function () {
+ // Get host URL from window config or fallback to root element
+ const rawHost = window.dashboardConfig?.hostUrl || document.querySelector('[data-host]')?.getAttribute('data-host') || '';
+ const host = rawHost.replace(/\/+$/, '');
+ const displayHost = host ? (host + '/') : '/';
+
+ const els = {
+ search: document.getElementById('f-search'),
+ status: document.getElementById('f-status'),
+ password: document.getElementById('f-password'),
+ maxClicks: document.getElementById('f-maxclicks'),
+ createdAfter: document.getElementById('f-created-after'),
+ createdBefore: document.getElementById('f-created-before'),
+ sortBy: document.getElementById('f-sortby'),
+ order: document.getElementById('f-order'),
+ pageSize: document.getElementById('f-pagesize'),
+ apply: document.getElementById('btn-apply'),
+ reset: document.getElementById('btn-reset'),
+ optionsBtn: document.getElementById('btn-options'),
+ optionsDropdown: document.getElementById('options-dropdown'),
+ loading: document.getElementById('list-loading'),
+ empty: document.getElementById('list-empty'),
+ list: document.getElementById('links-list'),
+ pagination: document.getElementById('pagination'),
+ tpl: document.getElementById('tpl-link-item'),
+ };
+
+ let state = {
+ page: 1,
+ hasNext: false,
+ total: 0,
+ pageSize: 20,
+ sortBy: 'last_click',
+ sortOrder: 'descending',
+ filters: {},
+ };
+
+ function toEpochSeconds(value) {
+ if (!value) return undefined;
+ try { return Math.floor(new Date(value).getTime() / 1000); } catch { return undefined; }
+ }
+
+ function buildQuery() {
+ const filter = {};
+ if (els.search.value.trim()) filter.search = els.search.value.trim();
+ if (els.status.value) filter.status = els.status.value;
+ if (els.password.value) filter.passwordSet = els.password.value;
+ if (els.maxClicks.value) filter.maxClicksSet = els.maxClicks.value;
+ if (els.createdAfter.value) filter.createdAfter = toEpochSeconds(els.createdAfter.value);
+ if (els.createdBefore.value) filter.createdBefore = toEpochSeconds(els.createdBefore.value);
+
+ const params = new URLSearchParams();
+ params.set('page', String(state.page));
+ params.set('pageSize', String(els.pageSize.value || state.pageSize));
+ params.set('sortBy', els.sortBy.value || state.sortBy);
+ params.set('sortOrder', els.order.value || state.sortOrder);
+ if (Object.keys(filter).length) { params.set('filter', JSON.stringify(filter)); }
+ return params.toString();
+ }
+
+ function setLoading(isLoading) {
+ els.loading.style.display = isLoading ? 'block' : 'none';
+ }
+
+ function clearList() {
+ els.list.innerHTML = '';
+ }
+
+ function formatDate(iso) {
+ if (!iso) return '—';
+ return window.SmartDatetime ? window.SmartDatetime.formatCreated(iso) :
+ (() => { try { return new Date(iso).toLocaleString(); } catch { return '—'; } })();
+ }
+
+ function formatTs(ts) {
+ if (!ts && ts !== 0) return '—';
+ return window.SmartDatetime ? window.SmartDatetime.formatLastClick(ts) :
+ (() => { try { return new Date(ts * 1000).toLocaleString(); } catch { return '—'; } })();
+ }
+
+ function trimProtocol(url) {
+ if (!url) return '';
+ return String(url).replace(/^https?:\/\//i, '');
+ }
+
+ function createItem(it) {
+ const node = els.tpl.content.firstElementChild.cloneNode(true);
+ const shortA = node.querySelector('.link-short');
+ const long = node.querySelector('.link-long');
+ const activeBadge = node.querySelector('.badge-active');
+ const inactiveBadge = node.querySelector('.badge-inactive');
+ const pwBadge = node.querySelector('.badge-password');
+ const mcBadge = node.querySelector('.badge-max-clicks');
+ const privBadge = node.querySelector('.badge-private');
+ const blockBotsBadge = node.querySelector('.badge-block-bots');
+ const created = node.querySelector('.created-date');
+ const last = node.querySelector('.last-click-date');
+ const total = node.querySelector('.total-clicks-count');
+
+ // Short URL
+ shortA.textContent = trimProtocol(displayHost) + (it.alias ? it.alias : '');
+ shortA.href = '/' + (it.alias || '');
+
+ // Long URL
+ long.textContent = it.long_url || '';
+ long.title = it.long_url || '';
+
+ // Dates and clicks
+ created.textContent = formatDate(it.created_at);
+ last.textContent = formatTs(it.last_click);
+ total.textContent = (it.total_clicks ?? '0');
+
+ // Status badges - show appropriate badge based on status
+ if (it.status === 'ACTIVE') {
+ activeBadge.style.display = 'inline-flex';
+ inactiveBadge.style.display = 'none';
+ } else if (it.status === 'INACTIVE') {
+ activeBadge.style.display = 'none';
+ inactiveBadge.style.display = 'inline-flex';
+ } else {
+ activeBadge.style.display = 'none';
+ inactiveBadge.style.display = 'none';
+ }
+
+ // Password badge
+ if (it.password_set) {
+ pwBadge.style.display = 'inline-flex';
+ }
+
+ // Max clicks badge
+ if (typeof it.max_clicks === 'number') {
+ mcBadge.style.display = 'inline-flex';
+ mcBadge.setAttribute('data-tooltip', `Max clicks: ${it.max_clicks}`);
+ }
+
+ // Private stats badge
+ if (it.private_stats) {
+ privBadge.style.display = 'inline-flex';
+ }
+
+ // Block bots badge
+ if (it.block_bots) {
+ blockBotsBadge.style.display = 'inline-flex';
+ }
+
+ // Store full URL data on the row for the modal
+ node.setAttribute('data-url-data', JSON.stringify(it));
+ node.style.cursor = 'pointer';
+ node.classList.add('clickable-row');
+
+ return node;
+ }
+
+ async function fetchData() {
+ setLoading(true);
+ els.empty.style.display = 'none';
+ try {
+ const qs = buildQuery();
+ const doFetch = (typeof window.authFetch === 'function') ? window.authFetch : fetch;
+ const res = await doFetch(`/api/v1/urls?${qs}`, { credentials: 'include' });
+ if (!res.ok) { throw new Error('Request failed'); }
+ const data = await res.json();
+ state.page = data.page;
+ state.pageSize = data.pageSize;
+ state.total = data.total;
+ state.hasNext = data.hasNext;
+ state.sortBy = data.sortBy;
+ state.sortOrder = data.sortOrder;
+
+ clearList();
+ if (!data.items || data.items.length === 0) {
+ els.empty.style.display = 'block';
+ document.getElementById('links-table').style.display = 'none';
+ els.pagination.style.display = 'none';
+ return;
+ }
+ document.getElementById('links-table').style.display = 'block';
+ const frag = document.createDocumentFragment();
+ for (const it of data.items) { frag.appendChild(createItem(it)); }
+ els.list.appendChild(frag);
+ renderPagination();
+
+ // Initialize tooltips for the newly created items
+ initializeTooltips();
+ } catch (err) {
+ clearList();
+ els.empty.style.display = 'block';
+ document.getElementById('links-table').style.display = 'none';
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ // Initialize Tippy.js tooltips for attribute badges
+ function initializeTooltips() {
+ // Destroy existing tooltips first
+ if (window.attributeTooltips) {
+ window.attributeTooltips.forEach(instance => instance.destroy());
+ }
+ window.attributeTooltips = [];
+
+ // Find all tooltip triggers and initialize Tippy.js
+ const tooltipTriggers = document.querySelectorAll('.tooltip-trigger[data-tooltip]');
+
+ tooltipTriggers.forEach(element => {
+ // Remove the title attribute to prevent native tooltips
+ element.removeAttribute('title');
+
+ const instance = tippy(element, {
+ content: element.getAttribute('data-tooltip'),
+ placement: 'top',
+ theme: 'dark',
+ animation: 'fade',
+ duration: [200, 150],
+ delay: [200, 0],
+ arrow: true,
+ hideOnClick: false,
+ trigger: 'mouseenter focus',
+ zIndex: 9999
+ });
+ window.attributeTooltips.push(instance);
+ });
+ }
+
+ function renderPagination() {
+ const totalPages = Math.max(1, Math.ceil(state.total / state.pageSize));
+ if (totalPages <= 1) { els.pagination.style.display = 'none'; return; }
+ els.pagination.style.display = 'flex';
+ const start = (state.page - 1) * state.pageSize + 1;
+ const end = Math.min(state.total, state.page * state.pageSize);
+ els.pagination.innerHTML = '';
+ const info = document.createElement('div');
+ info.className = 'pagination-info';
+ info.textContent = `Showing ${start} to ${end} of ${state.total}`;
+
+ const container = document.createElement('div');
+ container.className = 'pagination-controls';
+ const prev = document.createElement('button'); prev.className = 'btn'; prev.textContent = 'Prev'; prev.disabled = state.page <= 1;
+ const next = document.createElement('button'); next.className = 'btn'; next.textContent = 'Next'; next.disabled = !state.hasNext;
+ prev.addEventListener('click', () => { if (state.page > 1) { state.page -= 1; fetchData(); } });
+ next.addEventListener('click', () => { if (state.hasNext) { state.page += 1; fetchData(); } });
+ container.appendChild(prev);
+ container.appendChild(next);
+
+ els.pagination.appendChild(info);
+ els.pagination.appendChild(container);
+ }
+
+ function applyFilters() {
+ state.page = 1;
+ fetchData();
+ // Close the options dropdown with animation
+ if (els.optionsDropdown) {
+ els.optionsDropdown.classList.remove('show');
+ }
+ }
+
+ function resetFilters() {
+ for (const key of ['search', 'status', 'password', 'maxClicks', 'createdAfter', 'createdBefore']) {
+ if (els[key]) els[key].value = '';
+ }
+ if (els.sortBy) els.sortBy.value = 'last_click';
+ if (els.order) els.order.value = 'descending';
+ // reset segmented visual state
+ document.querySelectorAll('.seg').forEach(seg => {
+ const targetId = seg.getAttribute('data-target');
+ const hidden = document.getElementById(targetId);
+ const defaultValue = (targetId === 'f-order') ? 'descending' : '';
+ if (hidden) hidden.value = defaultValue;
+ seg.setAttribute('data-active', (targetId === 'f-order') ? '0' : '2');
+ });
+ if (els.pageSize) els.pageSize.value = '20';
+ applyFilters();
+ }
+
+ // wire events
+ els.apply.addEventListener('click', applyFilters);
+ els.reset.addEventListener('click', resetFilters);
+ els.search.addEventListener('keydown', (e) => { if (e.key === 'Enter') { applyFilters(); } });
+
+ // options dropdown toggle
+ function toggleOptions() {
+ if (!els.optionsDropdown) return;
+ const isOpen = els.optionsDropdown.classList.contains('show');
+ if (isOpen) {
+ els.optionsDropdown.classList.remove('show');
+ } else {
+ els.optionsDropdown.classList.add('show');
+ }
+ }
+ if (els.optionsBtn) { els.optionsBtn.addEventListener('click', toggleOptions); }
+ window.addEventListener('click', (e) => {
+ if (!els.optionsDropdown) return;
+ if (e.target === els.optionsBtn || els.optionsBtn.contains(e.target)) { return; }
+ if (!els.optionsDropdown.contains(e.target)) { els.optionsDropdown.classList.remove('show'); }
+ });
+
+ // segmented controls behavior
+ function initSegments() {
+ const segs = document.querySelectorAll('.seg');
+ segs.forEach(seg => {
+ const targetId = seg.getAttribute('data-target');
+ const hidden = document.getElementById(targetId);
+ const buttons = Array.from(seg.querySelectorAll('button[data-value]'));
+ const indexByValue = new Map(buttons.map((b, i) => [b.getAttribute('data-value'), i]));
+ function apply(value) {
+ if (hidden) { hidden.value = value; }
+ const idx = indexByValue.has(value) ? indexByValue.get(value) : 0;
+ seg.setAttribute('data-active', String(idx));
+ }
+ apply(hidden ? hidden.value : '');
+ buttons.forEach(btn => btn.addEventListener('click', () => apply(btn.getAttribute('data-value') || '')));
+ });
+ }
+
+ initSegments();
+
+ // Expose fetchData globally for other components to refresh the list
+ window.fetchData = fetchData;
+
+ // initial load
+ fetchData();
+})();
+
+
diff --git a/static/js/dashboard/dashboard-base.js b/static/js/dashboard/dashboard-base.js
new file mode 100644
index 00000000..eec4eb72
--- /dev/null
+++ b/static/js/dashboard/dashboard-base.js
@@ -0,0 +1,236 @@
+// Dashboard Base JavaScript
+document.addEventListener('DOMContentLoaded', function () {
+ // Sidebar toggle functionality
+ const sidebar = document.getElementById('sidebar');
+ const sidebarToggle = document.getElementById('sidebarToggle');
+ const profileButton = document.getElementById('profileButton');
+ const profileMenu = document.getElementById('profileMenu');
+ const navItems = document.querySelectorAll('.nav-item');
+
+ // Load sidebar state from localStorage
+ const sidebarState = localStorage.getItem('sidebarCollapsed');
+ if (sidebarState === 'true') {
+ sidebar.classList.add('collapsed');
+ }
+
+ // Toggle sidebar
+ sidebarToggle?.addEventListener('click', function () {
+ sidebar.classList.toggle('collapsed');
+ const isCollapsed = sidebar.classList.contains('collapsed');
+ localStorage.setItem('sidebarCollapsed', isCollapsed);
+
+ // Update toggle icon
+ const icon = sidebarToggle.querySelector('i');
+ if (icon) {
+ if (isCollapsed) {
+ icon.className = 'ti ti-layout-sidebar-left-expand';
+ } else {
+ icon.className = 'ti ti-layout-sidebar-right-expand';
+ }
+ }
+
+ // Close profile menu when collapsing
+ if (isCollapsed && profileMenu) {
+ profileMenu.classList.remove('active');
+ profileButton.classList.remove('active');
+ }
+ });
+
+ // Profile dropdown functionality
+ profileButton?.addEventListener('click', function (e) {
+ e.stopPropagation();
+ profileMenu.classList.toggle('active');
+ profileButton.classList.toggle('active');
+ });
+
+ // Close profile menu when clicking outside
+ document.addEventListener('click', function (e) {
+ if (profileMenu && !profileMenu.contains(e.target) && !profileButton.contains(e.target)) {
+ profileMenu.classList.remove('active');
+ profileButton.classList.remove('active');
+ }
+ });
+
+ // Prevent menu from closing when clicking inside it
+ profileMenu?.addEventListener('click', function (e) {
+ e.stopPropagation();
+ });
+
+ // Mobile menu functionality
+ const mobileMenuToggle = document.getElementById('mobileMenuToggle');
+ let sidebarOverlay = document.querySelector('.sidebar-overlay');
+
+ // Create overlay if it doesn't exist
+ if (!sidebarOverlay) {
+ sidebarOverlay = document.createElement('div');
+ sidebarOverlay.className = 'sidebar-overlay';
+ document.body.appendChild(sidebarOverlay);
+ }
+
+ // Mobile menu toggle handler
+ function toggleMobileSidebar() {
+ if (window.innerWidth <= 768) {
+ sidebar.classList.toggle('mobile-open');
+ sidebarOverlay.classList.toggle('active');
+
+ // Toggle mobile header visibility
+ const mobileHeader = document.querySelector('.mobile-header');
+ if (mobileHeader) {
+ mobileHeader.classList.toggle('sidebar-open');
+ }
+
+ // Update mobile menu icon
+ const icon = mobileMenuToggle?.querySelector('i');
+ if (icon) {
+ if (sidebar.classList.contains('mobile-open')) {
+ icon.className = 'ti ti-x';
+ } else {
+ icon.className = 'ti ti-menu-2';
+ }
+ }
+
+ // Prevent body scroll when sidebar is open
+ document.body.style.overflow = sidebar.classList.contains('mobile-open') ? 'hidden' : '';
+ }
+ }
+
+ // Close mobile sidebar
+ function closeMobileSidebar() {
+ if (window.innerWidth <= 768) {
+ sidebar.classList.remove('mobile-open');
+ sidebarOverlay.classList.remove('active');
+ document.body.style.overflow = '';
+
+ // Show mobile header again
+ const mobileHeader = document.querySelector('.mobile-header');
+ if (mobileHeader) {
+ mobileHeader.classList.remove('sidebar-open');
+ }
+
+ const icon = mobileMenuToggle?.querySelector('i');
+ if (icon) {
+ icon.className = 'ti ti-menu-2';
+ }
+ }
+ }
+
+ // Mobile menu toggle event
+ mobileMenuToggle?.addEventListener('click', toggleMobileSidebar);
+
+ // Close sidebar when clicking overlay
+ sidebarOverlay.addEventListener('click', closeMobileSidebar);
+
+ // Close sidebar when clicking nav items on mobile
+ navItems.forEach(item => {
+ item.addEventListener('click', () => {
+ if (window.innerWidth <= 768) {
+ closeMobileSidebar();
+ }
+ });
+ });
+
+ // Handle active navigation items
+ const currentPath = window.location.pathname;
+
+ navItems.forEach(item => {
+ const href = item.getAttribute('href');
+ if (href === currentPath) {
+ item.classList.add('active');
+ }
+ });
+
+ // Add keyboard shortcuts
+ document.addEventListener('keydown', function (e) {
+ // Ctrl+B (Windows/Linux) or Cmd+B (Mac) to toggle sidebar
+ if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
+ e.preventDefault();
+ if (window.innerWidth > 768) {
+ sidebarToggle?.click();
+ } else {
+ toggleMobileSidebar();
+ }
+ }
+
+ // Alt + S to toggle sidebar (desktop) or Escape to close mobile sidebar
+ if (e.altKey && e.key === 's' && window.innerWidth > 768) {
+ e.preventDefault();
+ sidebarToggle?.click();
+ }
+
+ // Escape to close mobile sidebar or profile menu
+ if (e.key === 'Escape') {
+ if (window.innerWidth <= 768 && sidebar.classList.contains('mobile-open')) {
+ closeMobileSidebar();
+ } else if (profileMenu?.classList.contains('active')) {
+ profileMenu.classList.remove('active');
+ profileButton.classList.remove('active');
+ }
+ }
+ });
+
+ // Handle window resize
+ let resizeTimer;
+ window.addEventListener('resize', function () {
+ clearTimeout(resizeTimer);
+ resizeTimer = setTimeout(function () {
+ if (window.innerWidth <= 768) {
+ // Mobile: ensure sidebar is closed and remove collapsed state
+ closeMobileSidebar();
+ sidebar.classList.remove('collapsed');
+ } else {
+ // Desktop: close mobile sidebar and restore collapsed state
+ closeMobileSidebar();
+
+ // Restore collapsed state from localStorage on desktop
+ const savedState = localStorage.getItem('sidebarCollapsed');
+ if (savedState === 'true') {
+ sidebar.classList.add('collapsed');
+ }
+ }
+ }, 250);
+ });
+
+ // Initialize Tippy.js tooltips for collapsed sidebar
+ let tippyInstances = [];
+
+ function initializeTooltips() {
+ // Destroy existing instances
+ tippyInstances.forEach(instance => instance.destroy());
+ tippyInstances = [];
+
+ // Only create tooltips if sidebar is collapsed
+ if (sidebar.classList.contains('collapsed')) {
+ const navItems = document.querySelectorAll('.nav-item[data-tooltip]');
+
+ navItems.forEach(item => {
+ const instance = tippy(item, {
+ content: item.getAttribute('data-tooltip'),
+ placement: 'right',
+ offset: [0, 12],
+ theme: 'dark',
+ animation: 'fade',
+ duration: [200, 150],
+ delay: [300, 0],
+ arrow: true,
+ hideOnClick: false,
+ trigger: 'mouseenter focus',
+ zIndex: 10000,
+ appendTo: 'parent'
+ });
+ tippyInstances.push(instance);
+ });
+ }
+ }
+
+ // Initialize tooltips on load if collapsed
+ initializeTooltips();
+
+ // Reinitialize tooltips when sidebar is toggled
+ const originalToggleHandler = sidebarToggle?.onclick;
+ sidebarToggle?.addEventListener('click', function () {
+ // Wait for the transition to complete
+ setTimeout(() => {
+ initializeTooltips();
+ }, 350); // Slightly longer than CSS transition
+ });
+});
diff --git a/static/js/dashboard/dateRangePicker.js b/static/js/dashboard/dateRangePicker.js
new file mode 100644
index 00000000..5ffd7d58
--- /dev/null
+++ b/static/js/dashboard/dateRangePicker.js
@@ -0,0 +1,497 @@
+/**
+ * Advanced Date Range Picker
+ * Supports relative time ranges and custom input formats
+ */
+class DateRangePicker {
+ constructor(options = {}) {
+ this.options = {
+ container: options.container || 'dateRangeContainer',
+ onRangeChange: options.onRangeChange || (() => {}),
+ defaultRange: options.defaultRange || 'last-7-days',
+ ...options
+ };
+
+ this.currentRange = this.options.defaultRange;
+ this.customFromValue = 'now-7d';
+ this.customToValue = 'now';
+ this.history = [];
+
+ this.init();
+ }
+
+ init() {
+ this.loadHistory();
+ this.render();
+ this.setupEventListeners();
+ this.updateRelativeSelection();
+ }
+
+ updateRelativeSelection() {
+ // Update the relative options to reflect current selection
+ document.querySelectorAll('.relative-option').forEach(option => {
+ option.classList.toggle('selected', option.dataset.value === this.currentRange);
+ });
+ }
+
+ render() {
+ const container = document.getElementById(this.options.container);
+ if (!container) return;
+
+ container.innerHTML = `
+
+
+
+ ${this.formatDisplayRange()}
+
+
+
+
+
+
+
+
+
Relative
+
+ ${this.renderRelativeOptions()}
+
+
+
+
+
+
Custom
+
+
+
+
History
+
+ ${this.renderHistory()}
+
+
+
+
+
+
+
+ `;
+ }
+
+ renderRelativeOptions() {
+ const options = [
+ { value: 'last-30-minutes', label: 'Last 30 minutes' },
+ { value: 'last-60-minutes', label: 'Last 60 minutes' },
+ { value: 'last-3-hours', label: 'Last 3 hours' },
+ { value: 'last-6-hours', label: 'Last 6 hours' },
+ { value: 'last-12-hours', label: 'Last 12 hours' },
+ { value: 'last-24-hours', label: 'Last 24 hours' },
+ { value: 'last-2-days', label: 'Last 2 days' },
+ { value: 'last-7-days', label: 'Last 7 days' },
+ { value: 'last-14-days', label: 'Last 14 days' },
+ { value: 'last-30-days', label: 'Last 30 days' },
+ { value: 'everything', label: 'Everything' },
+ { value: 'custom', label: 'Custom' }
+ ];
+
+ return options.map(option => `
+
+ ${option.label}
+
+ `).join('');
+ }
+
+ renderHistory() {
+ if (!this.history.length) {
+ return 'No recent selections
';
+ }
+
+ return this.history.map(item => `
+
+
+ ${item.display}
+
+ `).join('');
+ }
+
+ setupEventListeners() {
+ const trigger = document.getElementById('dateRangeTrigger');
+ const dropdown = document.getElementById('dateRangeDropdown');
+
+ // Toggle dropdown
+ trigger?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const isOpen = dropdown.style.display === 'block';
+
+ // Close all other modals if closeAllModals function exists
+ if (typeof window.closeAllModals === 'function') {
+ window.closeAllModals(window.dashboard);
+ }
+
+ // Only open if it was previously closed
+ if (!isOpen) {
+ dropdown.style.display = 'block';
+ trigger.classList.add('active');
+ }
+ });
+
+ // Close dropdown when clicking outside
+ document.addEventListener('click', (e) => {
+ if (!e.target.closest('.date-range-picker')) {
+ dropdown.style.display = 'none';
+ trigger.classList.remove('active');
+ }
+ });
+
+ // Relative options
+ document.querySelectorAll('.relative-option').forEach(option => {
+ option.addEventListener('click', () => {
+ this.selectRelativeRange(option.dataset.value);
+ });
+ });
+
+ // Custom range apply
+ document.getElementById('applyCustomRange')?.addEventListener('click', () => {
+ this.applyCustomRange();
+ });
+
+ // History items
+ document.querySelectorAll('.history-item').forEach(item => {
+ item.addEventListener('click', () => {
+ this.selectHistoryItem(item.dataset.from, item.dataset.to);
+ });
+ });
+
+ // Custom input validation
+ const customFrom = document.getElementById('customFrom');
+ const customTo = document.getElementById('customTo');
+
+ customFrom?.addEventListener('input', (e) => {
+ this.validateCustomInput(e.target);
+ });
+
+ customTo?.addEventListener('input', (e) => {
+ this.validateCustomInput(e.target);
+ });
+ }
+
+ selectRelativeRange(value) {
+ if (value === 'custom') {
+ // Focus on the custom input instead of tab switching
+ const customFrom = document.getElementById('customFrom');
+ if (customFrom) {
+ customFrom.focus();
+ }
+ return;
+ }
+
+ this.currentRange = value;
+
+ // Update UI
+ document.querySelectorAll('.relative-option').forEach(option => {
+ option.classList.toggle('selected', option.dataset.value === value);
+ });
+
+ // Update trigger text
+ const trigger = document.querySelector('.selected-range');
+ if (trigger) {
+ trigger.textContent = this.formatDisplayRange();
+ }
+
+ // Close dropdown
+ document.getElementById('dateRangeDropdown').style.display = 'none';
+ document.getElementById('dateRangeTrigger').classList.remove('active');
+
+ // Notify parent
+ const dateRange = this.parseRelativeRange(value);
+ this.options.onRangeChange(dateRange);
+ }
+
+ applyCustomRange() {
+ const fromInput = document.getElementById('customFrom');
+ const toInput = document.getElementById('customTo');
+
+ if (!fromInput || !toInput) return;
+
+ const fromValue = fromInput.value.trim();
+ const toValue = toInput.value.trim();
+
+ if (!fromValue || !toValue) {
+ this.showError('Both From and To fields are required');
+ return;
+ }
+
+ try {
+ const dateRange = this.parseCustomRange(fromValue, toValue);
+
+ // Add to history
+ this.addToHistory(fromValue, toValue, `${fromValue} - ${toValue}`);
+
+ // Update current values
+ this.customFromValue = fromValue;
+ this.customToValue = toValue;
+ this.currentRange = 'custom';
+
+ // Highlight the "Custom" option in relative section
+ this.updateRelativeSelection();
+
+ // Update trigger text
+ const trigger = document.querySelector('.selected-range');
+ if (trigger) {
+ trigger.textContent = `${fromValue} - ${toValue}`;
+ }
+
+ // Close dropdown
+ document.getElementById('dateRangeDropdown').style.display = 'none';
+ document.getElementById('dateRangeTrigger').classList.remove('active');
+
+ // Notify parent
+ this.options.onRangeChange(dateRange);
+
+ } catch (error) {
+ this.showError(error.message);
+ }
+ }
+
+ parseRelativeRange(range) {
+ // All datetime calculations use UTC timezone
+ const now = new Date();
+ let start, end = now;
+
+ switch (range) {
+ case 'last-30-minutes':
+ start = new Date(now.getTime() - 30 * 60 * 1000);
+ break;
+ case 'last-60-minutes':
+ start = new Date(now.getTime() - 60 * 60 * 1000);
+ break;
+ case 'last-3-hours':
+ start = new Date(now.getTime() - 3 * 60 * 60 * 1000);
+ break;
+ case 'last-6-hours':
+ start = new Date(now.getTime() - 6 * 60 * 60 * 1000);
+ break;
+ case 'last-12-hours':
+ start = new Date(now.getTime() - 12 * 60 * 60 * 1000);
+ break;
+ case 'last-24-hours':
+ start = new Date(now.getTime() - 24 * 60 * 60 * 1000);
+ break;
+ case 'last-2-days':
+ start = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
+ break;
+ case 'last-7-days':
+ start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+ break;
+ case 'last-14-days':
+ start = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
+ break;
+ case 'last-30-days':
+ start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+ break;
+ case 'everything':
+ start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000 * 3); // Last 3 months
+ break;
+ default:
+ start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+ }
+
+ return {
+ start: start.toISOString(),
+ end: end.toISOString(),
+ range: range
+ };
+ }
+
+ parseCustomRange(fromValue, toValue) {
+ const fromDate = this.parseCustomInput(fromValue);
+ const toDate = this.parseCustomInput(toValue);
+
+ if (fromDate >= toDate) {
+ throw new Error('From date must be before To date');
+ }
+
+ return {
+ start: fromDate.toISOString(),
+ end: toDate.toISOString(),
+ range: 'custom'
+ };
+ }
+
+ parseCustomInput(input) {
+ // All datetime parsing uses UTC timezone
+ input = input.trim().toLowerCase();
+
+ if (input === 'now') {
+ return new Date(); // UTC
+ }
+
+ // Parse relative formats like "now-30d", "now-2h", "now-15m", etc.
+ const relativeMatch = input.match(/^now-(\d+)([dhm])$/);
+ if (relativeMatch) {
+ const amount = parseInt(relativeMatch[1]);
+ const unit = relativeMatch[2];
+ const now = new Date();
+
+ switch (unit) {
+ case 'd': // days
+ return new Date(now.getTime() - amount * 24 * 60 * 60 * 1000);
+ case 'h': // hours
+ return new Date(now.getTime() - amount * 60 * 60 * 1000);
+ case 'm': // minutes
+ return new Date(now.getTime() - amount * 60 * 1000);
+ default:
+ throw new Error(`Invalid time unit: ${unit}`);
+ }
+ }
+
+ // Try parsing as regular date
+ const date = new Date(input);
+ if (isNaN(date.getTime())) {
+ throw new Error(`Invalid date format: ${input}. Use formats like "now", "now-30d", "now-2h", "now-15m", "2024-01-01", etc.`);
+ }
+
+ return date;
+ }
+
+ validateCustomInput(input) {
+ try {
+ this.parseCustomInput(input.value);
+ input.classList.remove('invalid');
+ input.classList.add('valid');
+ } catch (error) {
+ input.classList.remove('valid');
+ input.classList.add('invalid');
+ }
+ }
+
+ formatDisplayRange() {
+ const rangeLabels = {
+ 'last-30-minutes': 'Last 30 minutes',
+ 'last-60-minutes': 'Last 60 minutes',
+ 'last-3-hours': 'Last 3 hours',
+ 'last-6-hours': 'Last 6 hours',
+ 'last-12-hours': 'Last 12 hours',
+ 'last-24-hours': 'Last 24 hours',
+ 'last-2-days': 'Last 2 days',
+ 'last-7-days': 'Last 7 days',
+ 'last-14-days': 'Last 14 days',
+ 'last-30-days': 'Last 30 days',
+ 'everything': 'Everything',
+ 'custom': `${this.customFromValue || 'now-7d'} - ${this.customToValue || 'now'}`
+ };
+
+ return rangeLabels[this.currentRange] || 'Last 7 days';
+ }
+
+ addToHistory(from, to, display) {
+ const historyItem = { from, to, display, timestamp: Date.now() };
+
+ // Remove duplicates
+ this.history = this.history.filter(item =>
+ !(item.from === from && item.to === to)
+ );
+
+ // Add to beginning
+ this.history.unshift(historyItem);
+
+ // Keep only last 10 items
+ this.history = this.history.slice(0, 10);
+
+ // Save to localStorage
+ this.saveHistory();
+
+ // Update UI
+ this.updateHistoryUI();
+ }
+
+ loadHistory() {
+ try {
+ const saved = localStorage.getItem('dateRangeHistory');
+ if (saved) {
+ const savedHistory = JSON.parse(saved);
+ this.history = savedHistory.length > 0 ? savedHistory : this.getDefaultHistory();
+ } else {
+ this.history = this.getDefaultHistory();
+ }
+ } catch (error) {
+ console.warn('Failed to load date range history:', error);
+ this.history = this.getDefaultHistory();
+ }
+ }
+
+ getDefaultHistory() {
+ return [
+ { from: 'now-7d', to: 'now-3d', display: 'now-7d - now-3d', timestamp: Date.now() - 86400000 },
+ { from: 'now-7d', to: 'now-4d', display: 'now-7d - now-4d', timestamp: Date.now() - 172800000 },
+ { from: 'now-7d', to: 'now-1d', display: 'now-7d - now-1d', timestamp: Date.now() - 259200000 },
+ { from: 'now-30d', to: 'now-1d', display: 'now-30d - now-1d', timestamp: Date.now() - 345600000 },
+ { from: 'now-30d', to: 'now-5d', display: 'now-30d - now-5d', timestamp: Date.now() - 432000000 },
+ { from: 'now-30d', to: 'now-10d', display: 'now-30d - now-10d', timestamp: Date.now() - 518400000 },
+ { from: 'now-30d', to: 'now-20d', display: 'now-30d - now-20d', timestamp: Date.now() - 604800000 }
+ ];
+ }
+
+ saveHistory() {
+ try {
+ localStorage.setItem('dateRangeHistory', JSON.stringify(this.history));
+ } catch (error) {
+ console.warn('Failed to save date range history:', error);
+ }
+ }
+
+ updateHistoryUI() {
+ const historyList = document.getElementById('historyList');
+ if (historyList) {
+ historyList.innerHTML = this.renderHistory();
+
+ // Re-attach event listeners
+ document.querySelectorAll('.history-item').forEach(item => {
+ item.addEventListener('click', () => {
+ this.selectHistoryItem(item.dataset.from, item.dataset.to);
+ });
+ });
+ }
+ }
+
+ selectHistoryItem(from, to) {
+ document.getElementById('customFrom').value = from;
+ document.getElementById('customTo').value = to;
+ this.applyCustomRange();
+ }
+
+ showError(message) {
+ // Simple error display - can be enhanced with toast notifications
+ alert(message);
+ }
+
+ getCurrentRange() {
+ if (this.currentRange === 'custom') {
+ return this.parseCustomRange(this.customFromValue, this.customToValue);
+ } else {
+ return this.parseRelativeRange(this.currentRange);
+ }
+ }
+
+ setRange(range) {
+ this.currentRange = range;
+ this.render();
+ // Reattach event listeners after DOM is recreated
+ this.setupEventListeners();
+ // Refresh UI selection state
+ this.updateRelativeSelection();
+ }
+}
+
+// Export for use in other modules
+window.DateRangePicker = DateRangePicker;
\ No newline at end of file
diff --git a/static/js/dashboard/keys.js b/static/js/dashboard/keys.js
new file mode 100644
index 00000000..eeff57e6
--- /dev/null
+++ b/static/js/dashboard/keys.js
@@ -0,0 +1,399 @@
+// API Keys Management JavaScript
+const keyElements = {
+ loading: document.getElementById('keys-loading'),
+ empty: document.getElementById('keys-empty'),
+ table: document.getElementById('keys-table'),
+ list: document.getElementById('keys-list'),
+ template: document.getElementById('tpl-key-item'),
+ newKeyBtn: document.getElementById('btn-new-key'),
+ createModal: document.getElementById('createKeyModal'),
+ successModal: document.getElementById('keySuccessModal'),
+ createBtn: document.getElementById('btn-create'),
+ tokenInput: document.getElementById('fullTokenInput')
+};
+
+async function fetchKeys() {
+ setKeysLoading(true);
+ keyElements.empty.style.display = 'none';
+
+ try {
+ const res = await authFetch('/api/v1/keys', { headers: { 'Accept': 'application/json' } });
+ if (!res.ok) throw new Error('Failed to fetch keys');
+
+ const data = await res.json();
+ renderKeys(data.keys || []);
+ } catch (error) {
+ console.error('Error fetching keys:', error);
+ showEmptyState();
+ } finally {
+ setKeysLoading(false);
+ }
+}
+
+function setKeysLoading(loading) {
+ keyElements.loading.style.display = loading ? 'flex' : 'none';
+ // Don't automatically show table when loading is false - let renderKeys handle it
+ if (loading) {
+ keyElements.table.style.display = 'none';
+ keyElements.empty.style.display = 'none';
+ }
+}
+
+function showEmptyState() {
+ keyElements.empty.style.display = 'flex';
+ keyElements.table.style.display = 'none';
+}
+
+function renderKeys(keys) {
+ keyElements.list.innerHTML = '';
+
+ if (!keys || keys.length === 0) {
+ showEmptyState();
+ return;
+ }
+
+ keyElements.table.style.display = 'block';
+ keyElements.empty.style.display = 'none';
+
+ const fragment = document.createDocumentFragment();
+ keys.forEach(key => {
+ const row = createKeyRow(key);
+ fragment.appendChild(row);
+ });
+
+ keyElements.list.appendChild(fragment);
+}
+
+function createKeyRow(key) {
+ const node = keyElements.template.content.firstElementChild.cloneNode(true);
+
+ // Name and description
+ const nameEl = node.querySelector('.key-name');
+ const descEl = node.querySelector('.key-description');
+ nameEl.textContent = key.name || '(no name)';
+ descEl.textContent = key.description || '';
+ if (!key.description) descEl.style.display = 'none';
+
+ // Key prefix
+ const prefixEl = node.querySelector('.key-prefix');
+ prefixEl.textContent = `spoo_${key.token_prefix || ''}…`;
+
+ // Scopes
+ const scopesEl = node.querySelector('.scopes-list');
+ if (key.scopes && key.scopes.length > 0) {
+ key.scopes.forEach(scope => {
+ const tag = document.createElement('span');
+ tag.className = 'scope-tag';
+ tag.textContent = scope;
+ scopesEl.appendChild(tag);
+ });
+ } else {
+ scopesEl.innerHTML = 'none ';
+ }
+
+ // Dates
+ const createdEl = node.querySelector('.created-date');
+ const expiresEl = node.querySelector('.expires-date');
+ createdEl.textContent = key.created_at ? new Date(key.created_at * 1000).toLocaleDateString() : '—';
+ expiresEl.textContent = key.expires_at ? new Date(key.expires_at * 1000).toLocaleDateString() : 'Never';
+
+ // Status
+ const statusEl = node.querySelector('.status-badge');
+ const status = key.revoked ? 'revoked' : (key.expires_at && key.expires_at * 1000 < Date.now() ? 'expired' : 'active');
+ statusEl.textContent = status.toUpperCase();
+ statusEl.className = `status-badge status-${status}`;
+
+ // Delete/Revoke button
+ const revokeBtn = node.querySelector('.btn-revoke');
+ revokeBtn.disabled = key.revoked;
+ revokeBtn.textContent = key.revoked ? 'Deleted' : 'Delete';
+ revokeBtn.setAttribute('data-id', key.id);
+
+ if (!key.revoked) {
+ revokeBtn.addEventListener('click', () => revokeKey(key.id));
+ }
+
+ return node;
+}
+
+async function revokeKey(keyId) {
+ if (!confirm('Delete this key permanently? This action cannot be undone.')) return;
+
+ try {
+ const res = await authFetch(`/api/v1/keys/${keyId}`, {
+ method: 'DELETE',
+ headers: { 'Accept': 'application/json' }
+ });
+
+ if (res.ok) {
+ const data = await res.json().catch(() => ({}));
+ const action = data.action || 'deleted';
+ customTopNotification('KeyDeleted', `Key ${action} successfully`, 6, 'success');
+ fetchKeys();
+ } else {
+ customTopNotification('KeyRevokeError', 'Failed to revoke key', 8, 'error');
+ }
+ } catch (error) {
+ customTopNotification('KeyRevokeError', 'Failed to revoke key', 8, 'error');
+ }
+}
+
+// Modal Management
+function openCreateKeyModal() {
+ keyElements.createModal.style.display = 'flex';
+ document.body.style.overflow = 'hidden';
+
+ // Focus first input
+ setTimeout(() => {
+ document.getElementById('key-name').focus();
+ }, 100);
+}
+
+function closeCreateKeyModal() {
+ keyElements.createModal.style.display = 'none';
+ document.body.style.overflow = '';
+
+ // Reset form
+ resetCreateForm();
+}
+
+function openKeySuccessModal(token, tokenPrefix) {
+ closeCreateKeyModal();
+
+ keyElements.tokenInput.value = token;
+ keyElements.successModal.style.display = 'flex';
+ document.body.style.overflow = 'hidden';
+
+ // Reload keys list to show the new key
+ fetchKeys();
+
+ // Auto-copy token to clipboard
+ setTimeout(async () => {
+ try {
+ await navigator.clipboard.writeText(token);
+
+ // Visual feedback on copy button
+ const copyBtn = document.querySelector('.copy-btn i');
+ if (copyBtn) {
+ copyBtn.className = 'ti ti-check';
+ setTimeout(() => {
+ copyBtn.className = 'ti ti-copy';
+ }, 2000);
+ }
+ } catch (error) {
+ console.log('Auto-copy failed, user can copy manually');
+ }
+ }, 100);
+}
+
+function closeKeySuccessModal() {
+ keyElements.successModal.style.display = 'none';
+ document.body.style.overflow = '';
+ fetchKeys(); // Refresh the list
+}
+
+function resetCreateForm() {
+ document.getElementById('key-name').value = '';
+ document.getElementById('key-description').value = '';
+ document.getElementById('key-expires').value = '';
+
+ // Reset access level to full access (default)
+ document.getElementById('full-access').checked = true;
+ document.getElementById('custom-access').checked = false;
+
+ // Reset permission checkboxes (will be set by handleAccessLevelChange)
+ document.querySelectorAll('.scope-input').forEach(input => {
+ input.checked = input.value === 'shorten:create';
+ });
+
+ // Reset access level state to full access
+ handleAccessLevelChange('full');
+}
+
+async function createKey() {
+ const name = document.getElementById('key-name').value.trim();
+ const description = document.getElementById('key-description').value.trim();
+ const expiresAt = document.getElementById('key-expires').value;
+ const accessLevel = document.querySelector('input[name="access-level"]:checked').value;
+
+ let scopes;
+ if (accessLevel === 'full') {
+ scopes = ['admin:all'];
+ } else {
+ // Custom access uses selected permissions
+ scopes = Array.from(document.querySelectorAll('.scope-input:checked')).map(i => i.value);
+ }
+
+ if (!name || scopes.length === 0) {
+ customTopNotification('KeyCreateError', 'Name and at least one permission are required', 8, 'error');
+ return;
+ }
+
+ const body = {
+ name,
+ description: description || undefined,
+ scopes,
+ expires_at: expiresAt || undefined
+ };
+
+ // Disable button and show loading state
+ const createBtn = keyElements.createBtn;
+ const originalText = createBtn.innerHTML;
+ createBtn.disabled = true;
+ createBtn.innerHTML = ' Creating...';
+
+ try {
+ const res = await authFetch('/api/v1/keys', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
+ body: JSON.stringify(body)
+ });
+
+ const data = await res.json().catch(() => ({}));
+
+ if (!res.ok) {
+ // Close modal first so notification is visible
+ closeCreateKeyModal();
+
+ // Check for email verification error
+ if (data.code === 'EMAIL_NOT_VERIFIED') {
+ if (typeof showVerificationModal === 'function') {
+ showVerificationModal('create API keys');
+ }
+ return;
+ }
+
+ // Improved error messages
+ let errorMessage = data.error || 'Failed to create key';
+
+ // Handle specific error cases
+ if (res.status === 429 || errorMessage.toLowerCase().includes('ratelimit')) {
+ errorMessage = 'Rate limit exceeded. You can create up to 5 keys per hour. Please try again later.';
+ } else if (errorMessage.includes('maximum') && errorMessage.includes('active keys')) {
+ errorMessage = 'Maximum active keys limit reached (20). Please delete some unused keys first.';
+ }
+
+ customTopNotification('KeyCreateError', errorMessage, 10, 'error');
+ return;
+ }
+
+ openKeySuccessModal(data.token, data.token_prefix);
+
+ } catch (error) {
+ // Close modal on network error too
+ closeCreateKeyModal();
+ customTopNotification('KeyCreateError', 'Network error. Please try again.', 8, 'error');
+ } finally {
+ // Re-enable button and restore original text
+ createBtn.disabled = false;
+ createBtn.innerHTML = originalText;
+ }
+}
+
+async function copyTokenToClipboard() {
+ try {
+ await navigator.clipboard.writeText(keyElements.tokenInput.value);
+ customTopNotification('KeyCopied', 'API key copied to clipboard', 5, 'success');
+
+ // Visual feedback
+ const copyBtn = document.querySelector('.copy-btn');
+ const originalIcon = copyBtn.querySelector('i');
+ originalIcon.className = 'ti ti-check';
+ setTimeout(() => {
+ originalIcon.className = 'ti ti-copy';
+ }, 2000);
+
+ } catch (error) {
+ customTopNotification('KeyCopyError', 'Failed to copy key', 8, 'error');
+ }
+}
+
+// Access Level Management
+function setupAccessLevelHandlers() {
+ const accessRadios = document.querySelectorAll('input[name="access-level"]');
+ const detailedPermissions = document.getElementById('detailed-permissions');
+ const accessOptions = document.querySelectorAll('.access-option');
+
+ accessRadios.forEach(radio => {
+ radio.addEventListener('change', function () {
+ handleAccessLevelChange(this.value);
+ });
+ });
+
+ // Initialize with current state
+ const selectedLevel = document.querySelector('input[name="access-level"]:checked')?.value || 'full';
+ handleAccessLevelChange(selectedLevel);
+}
+
+function handleAccessLevelChange(level) {
+ const detailedPermissions = document.getElementById('detailed-permissions');
+ const scopeInputs = document.querySelectorAll('.scope-input');
+
+ if (level === 'full') {
+ // Hide detailed permissions with animation
+ detailedPermissions.classList.add('hidden');
+ } else if (level === 'custom') {
+ // Show detailed permissions with animation
+ setTimeout(() => {
+ detailedPermissions.classList.remove('hidden');
+ }, 100);
+
+ // Reset to only "Create URLs" selected by default
+ scopeInputs.forEach(input => {
+ input.checked = input.value === 'shorten:create';
+ });
+ }
+}
+
+// Event Listeners
+document.addEventListener('DOMContentLoaded', function () {
+ fetchKeys();
+
+ // New key button
+ keyElements.newKeyBtn?.addEventListener('click', openCreateKeyModal);
+
+ // Create key button
+ keyElements.createBtn?.addEventListener('click', createKey);
+
+ // Access level handling
+ setupAccessLevelHandlers();
+
+ // Modal backdrop clicks
+ keyElements.createModal?.addEventListener('click', (e) => {
+ if (e.target === keyElements.createModal || e.target.classList.contains('modal-overlay')) {
+ closeCreateKeyModal();
+ }
+ });
+
+ keyElements.successModal?.addEventListener('click', (e) => {
+ if (e.target === keyElements.successModal || e.target.classList.contains('modal-overlay')) {
+ closeKeySuccessModal();
+ }
+ });
+
+ // Escape key to close modals
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ if (keyElements.createModal.style.display === 'flex') {
+ closeCreateKeyModal();
+ } else if (keyElements.successModal.style.display === 'flex') {
+ closeKeySuccessModal();
+ }
+ }
+ });
+
+ // Token input click to copy
+ keyElements.tokenInput?.addEventListener('click', copyTokenToClipboard);
+
+ // Enter key in name field to create
+ document.getElementById('key-name')?.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ createKey();
+ }
+ });
+});
+
+// Global functions for onclick handlers
+window.closeCreateKeyModal = closeCreateKeyModal;
+window.closeKeySuccessModal = closeKeySuccessModal;
+window.copyTokenToClipboard = copyTokenToClipboard;
\ No newline at end of file
diff --git a/static/js/dashboard/smart-datetime.js b/static/js/dashboard/smart-datetime.js
new file mode 100644
index 00000000..e37e1362
--- /dev/null
+++ b/static/js/dashboard/smart-datetime.js
@@ -0,0 +1,83 @@
+/**
+ * Smart Datetime Formatter using Day.js
+ */
+(function () {
+ // Initialize day.js plugins
+ if (typeof dayjs !== 'undefined') {
+ dayjs.extend(dayjs_plugin_relativeTime);
+ }
+
+ // Vanilla JS helpers to replace the three plugins
+ function isToday(dayjsObj) {
+ const today = dayjs();
+ return dayjsObj.format('YYYY-MM-DD') === today.format('YYYY-MM-DD');
+ }
+
+ function isYesterday(dayjsObj) {
+ const yesterday = dayjs().subtract(1, 'day');
+ return dayjsObj.format('YYYY-MM-DD') === yesterday.format('YYYY-MM-DD');
+ }
+
+ function isSameOrAfter(dayjsObj, compareObj, unit) {
+ return dayjsObj.valueOf() >= compareObj.valueOf();
+ }
+
+ function smartFormat(dateInput, showRelativeToday = false) {
+ if (!dateInput) return '—';
+
+ try {
+ // Handle different input types
+ let d;
+ if (typeof dateInput === 'number') {
+ d = dateInput > 10000000000 ? dayjs(dateInput) : dayjs(dateInput * 1000);
+ } else {
+ d = dayjs(dateInput);
+ }
+
+ if (!d.isValid()) return '—';
+
+ const now = dayjs();
+
+ // If today and showRelativeToday enabled
+ if (isToday(d) && showRelativeToday) {
+ const hoursAgo = now.diff(d, 'hour');
+ if (hoursAgo < 1) return d.fromNow();
+ return `${hoursAgo}h ago`;
+ }
+
+ // Standard formats
+ if (isToday(d)) return `Today at ${d.format('h:mm A')}`;
+ if (isYesterday(d)) return `Yesterday at ${d.format('h:mm A')}`;
+ if (isSameOrAfter(d, now.subtract(7, 'day'), 'day')) return d.format('ddd, h:mm A');
+ if (d.year() === now.year()) return d.format('D MMM, h:mm A');
+ return d.format('D MMM YYYY, h:mm A');
+
+ } catch (error) {
+ return '—';
+ }
+ }
+
+ /**
+ * Format for created dates (less relative)
+ */
+ function formatCreated(dateInput) {
+ return smartFormat(dateInput, false);
+ }
+
+ /**
+ * Format for last click dates (more relative)
+ */
+ function formatLastClick(dateInput) {
+ return smartFormat(dateInput, true);
+ }
+
+ // Export functions
+ window.SmartDatetime = {
+ formatCreated: formatCreated,
+ formatLastClick: formatLastClick
+ };
+
+ // Backward compatibility
+ window.formatDate = formatCreated;
+ window.formatTs = formatLastClick;
+})();
\ No newline at end of file
diff --git a/static/js/dashboard/statistics.js b/static/js/dashboard/statistics.js
new file mode 100644
index 00000000..889bb243
--- /dev/null
+++ b/static/js/dashboard/statistics.js
@@ -0,0 +1,2294 @@
+// Statistics Dashboard JavaScript - Using new /api/v1/stats endpoint
+// Phase 1 Refactor: introduce configuration-driven categorical chart builder & shared constants
+
+// Central list of filter types used across UI & API param building (keeps country & short_code aligned)
+const FILTER_TYPES = ['browser', 'os', 'country', 'city', 'referrer', 'short_code'];
+
+// Top N threshold for categorical charts before grouping remaining into "Others"
+const TOP_N = 7;
+
+// Country code map extracted from method for reuse & clarity (kept identical)
+const COUNTRY_MAP = {
+ 'AD': 'Andorra', 'AE': 'United Arab Emirates', 'AF': 'Afghanistan', 'AG': 'Antigua and Barbuda',
+ 'AI': 'Anguilla', 'AL': 'Albania', 'AM': 'Armenia', 'AO': 'Angola', 'AQ': 'Antarctica',
+ 'AR': 'Argentina', 'AS': 'American Samoa', 'AT': 'Austria', 'AU': 'Australia', 'AW': 'Aruba',
+ 'AX': 'Åland Islands', 'AZ': 'Azerbaijan', 'BA': 'Bosnia and Herzegovina', 'BB': 'Barbados',
+ 'BD': 'Bangladesh', 'BE': 'Belgium', 'BF': 'Burkina Faso', 'BG': 'Bulgaria', 'BH': 'Bahrain',
+ 'BI': 'Burundi', 'BJ': 'Benin', 'BL': 'Saint Barthélemy', 'BM': 'Bermuda', 'BN': 'Brunei',
+ 'BO': 'Bolivia', 'BQ': 'Caribbean Netherlands', 'BR': 'Brazil', 'BS': 'Bahamas', 'BT': 'Bhutan',
+ 'BV': 'Bouvet Island', 'BW': 'Botswana', 'BY': 'Belarus', 'BZ': 'Belize', 'CA': 'Canada',
+ 'CC': 'Cocos Islands', 'CD': 'Democratic Republic of the Congo', 'CF': 'Central African Republic',
+ 'CG': 'Republic of the Congo', 'CH': 'Switzerland', 'CI': 'Côte d\'Ivoire', 'CK': 'Cook Islands',
+ 'CL': 'Chile', 'CM': 'Cameroon', 'CN': 'China', 'CO': 'Colombia', 'CR': 'Costa Rica',
+ 'CU': 'Cuba', 'CV': 'Cape Verde', 'CW': 'Curaçao', 'CX': 'Christmas Island', 'CY': 'Cyprus',
+ 'CZ': 'Czech Republic', 'DE': 'Germany', 'DJ': 'Djibouti', 'DK': 'Denmark', 'DM': 'Dominica',
+ 'DO': 'Dominican Republic', 'DZ': 'Algeria', 'EC': 'Ecuador', 'EE': 'Estonia', 'EG': 'Egypt',
+ 'EH': 'Western Sahara', 'ER': 'Eritrea', 'ES': 'Spain', 'ET': 'Ethiopia', 'FI': 'Finland',
+ 'FJ': 'Fiji', 'FK': 'Falkland Islands', 'FM': 'Micronesia', 'FO': 'Faroe Islands', 'FR': 'France',
+ 'GA': 'Gabon', 'GB': 'United Kingdom', 'GD': 'Grenada', 'GE': 'Georgia', 'GF': 'French Guiana',
+ 'GG': 'Guernsey', 'GH': 'Ghana', 'GI': 'Gibraltar', 'GL': 'Greenland', 'GM': 'Gambia',
+ 'GN': 'Guinea', 'GP': 'Guadeloupe', 'GQ': 'Equatorial Guinea', 'GR': 'Greece', 'GS': 'South Georgia',
+ 'GT': 'Guatemala', 'GU': 'Guam', 'GW': 'Guinea-Bissau', 'GY': 'Guyana', 'HK': 'Hong Kong',
+ 'HM': 'Heard Island', 'HN': 'Honduras', 'HR': 'Croatia', 'HT': 'Haiti', 'HU': 'Hungary',
+ 'ID': 'Indonesia', 'IE': 'Ireland', 'IL': 'Israel', 'IM': 'Isle of Man', 'IN': 'India',
+ 'IO': 'British Indian Ocean Territory', 'IQ': 'Iraq', 'IR': 'Iran', 'IS': 'Iceland', 'IT': 'Italy',
+ 'JE': 'Jersey', 'JM': 'Jamaica', 'JO': 'Jordan', 'JP': 'Japan', 'KE': 'Kenya', 'KG': 'Kyrgyzstan',
+ 'KH': 'Cambodia', 'KI': 'Kiribati', 'KM': 'Comoros', 'KN': 'Saint Kitts and Nevis', 'KP': 'North Korea',
+ 'KR': 'South Korea', 'KW': 'Kuwait', 'KY': 'Cayman Islands', 'KZ': 'Kazakhstan', 'LA': 'Laos',
+ 'LB': 'Lebanon', 'LC': 'Saint Lucia', 'LI': 'Liechtenstein', 'LK': 'Sri Lanka', 'LR': 'Liberia',
+ 'LS': 'Lesotho', 'LT': 'Lithuania', 'LU': 'Luxembourg', 'LV': 'Latvia', 'LY': 'Libya',
+ 'MA': 'Morocco', 'MC': 'Monaco', 'MD': 'Moldova', 'ME': 'Montenegro', 'MF': 'Saint Martin',
+ 'MG': 'Madagascar', 'MH': 'Marshall Islands', 'MK': 'North Macedonia', 'ML': 'Mali', 'MM': 'Myanmar',
+ 'MN': 'Mongolia', 'MO': 'Macao', 'MP': 'Northern Mariana Islands', 'MQ': 'Martinique', 'MR': 'Mauritania',
+ 'MS': 'Montserrat', 'MT': 'Malta', 'MU': 'Mauritius', 'MV': 'Maldives', 'MW': 'Malawi',
+ 'MX': 'Mexico', 'MY': 'Malaysia', 'MZ': 'Mozambique', 'NA': 'Namibia', 'NC': 'New Caledonia',
+ 'NE': 'Niger', 'NF': 'Norfolk Island', 'NG': 'Nigeria', 'NI': 'Nicaragua', 'NL': 'Netherlands',
+ 'NO': 'Norway', 'NP': 'Nepal', 'NR': 'Nauru', 'NU': 'Niue', 'NZ': 'New Zealand', 'OM': 'Oman',
+ 'PA': 'Panama', 'PE': 'Peru', 'PF': 'French Polynesia', 'PG': 'Papua New Guinea', 'PH': 'Philippines',
+ 'PK': 'Pakistan', 'PL': 'Poland', 'PM': 'Saint Pierre and Miquelon', 'PN': 'Pitcairn Islands',
+ 'PR': 'Puerto Rico', 'PS': 'Palestine', 'PT': 'Portugal', 'PW': 'Palau', 'PY': 'Paraguay',
+ 'QA': 'Qatar', 'RE': 'Réunion', 'RO': 'Romania', 'RS': 'Serbia', 'RU': 'Russia', 'RW': 'Rwanda',
+ 'SA': 'Saudi Arabia', 'SB': 'Solomon Islands', 'SC': 'Seychelles', 'SD': 'Sudan', 'SE': 'Sweden',
+ 'SG': 'Singapore', 'SH': 'Saint Helena', 'SI': 'Slovenia', 'SJ': 'Svalbard and Jan Mayen',
+ 'SK': 'Slovakia', 'SL': 'Sierra Leone', 'SM': 'San Marino', 'SN': 'Senegal', 'SO': 'Somalia',
+ 'SR': 'Suriname', 'SS': 'South Sudan', 'ST': 'São Tomé and Príncipe', 'SV': 'El Salvador',
+ 'SX': 'Sint Maarten', 'SY': 'Syria', 'SZ': 'Eswatini', 'TC': 'Turks and Caicos Islands',
+ 'TD': 'Chad', 'TF': 'French Southern Territories', 'TG': 'Togo', 'TH': 'Thailand', 'TJ': 'Tajikistan',
+ 'TK': 'Tokelau', 'TL': 'Timor-Leste', 'TM': 'Turkmenistan', 'TN': 'Tunisia', 'TO': 'Tonga',
+ 'TR': 'Turkey', 'TT': 'Trinidad and Tobago', 'TV': 'Tuvalu', 'TW': 'Taiwan', 'TZ': 'Tanzania',
+ 'UA': 'Ukraine', 'UG': 'Uganda', 'UM': 'United States Minor Outlying Islands', 'US': 'United States',
+ 'UY': 'Uruguay', 'UZ': 'Uzbekistan', 'VA': 'Vatican City', 'VC': 'Saint Vincent and the Grenadines',
+ 'VE': 'Venezuela', 'VG': 'British Virgin Islands', 'VI': 'United States Virgin Islands',
+ 'VN': 'Vietnam', 'VU': 'Vanuatu', 'WF': 'Wallis and Futuna', 'WS': 'Samoa', 'YE': 'Yemen',
+ 'YT': 'Mayotte', 'ZA': 'South Africa', 'ZM': 'Zambia', 'ZW': 'Zimbabwe', 'XX': 'Unknown'
+};
+
+// Configuration map for categorical (bar) charts (excludes timeSeries & country map which are special)
+// Each entry defines how to extract & render that dimension.
+const CHART_CONFIGS = {
+ browser: {
+ id: 'browserChart',
+ metricBase: 'browser',
+ totalKey: 'clicks_by_browser',
+ uniqueKey: 'unique_clicks_by_browser',
+ totalLabel: 'Browsers',
+ uniqueLabel: 'Unique Browsers',
+ colors: {
+ total: { bg: 'rgba(59, 130, 246, 0.2)', border: 'rgba(59, 130, 246, 0.9)' }, // modern blue
+ unique: { bg: 'rgba(147, 197, 253, 0.4)', border: 'rgba(147, 197, 253, 1)' } // light blue
+ },
+ defaultMode: 'compare'
+ },
+ os: {
+ id: 'osChart',
+ metricBase: 'os',
+ totalKey: 'clicks_by_os',
+ uniqueKey: 'unique_clicks_by_os',
+ totalLabel: 'Platforms',
+ uniqueLabel: 'Unique Platforms',
+ colors: {
+ total: { bg: 'rgba(16, 185, 129, 0.2)', border: 'rgba(16, 185, 129, 0.9)' }, // emerald green
+ unique: { bg: 'rgba(110, 231, 183, 0.4)', border: 'rgba(110, 231, 183, 1)' } // light emerald
+ },
+ defaultMode: 'compare'
+ },
+ referrer: {
+ id: 'referrerChart',
+ metricBase: 'referrer',
+ totalKey: 'clicks_by_referrer',
+ uniqueKey: 'unique_clicks_by_referrer',
+ totalLabel: 'Referrers',
+ uniqueLabel: 'Unique Referrers',
+ colors: {
+ total: { bg: 'rgba(245, 158, 11, 0.2)', border: 'rgba(245, 158, 11, 0.9)' }, // amber
+ unique: { bg: 'rgba(252, 211, 77, 0.4)', border: 'rgba(252, 211, 77, 1)' } // light amber
+ },
+ defaultMode: 'compare'
+ },
+ city: {
+ id: 'cityChart',
+ metricBase: 'city',
+ totalKey: 'clicks_by_city',
+ uniqueKey: 'unique_clicks_by_city',
+ totalLabel: 'Cities',
+ uniqueLabel: 'Unique Cities',
+ colors: {
+ total: { bg: 'rgba(239, 68, 68, 0.2)', border: 'rgba(239, 68, 68, 0.9)' }, // modern red
+ unique: { bg: 'rgba(252, 165, 165, 0.4)', border: 'rgba(252, 165, 165, 1)' } // light red
+ },
+ defaultMode: 'compare'
+ },
+ short_code: {
+ id: 'keyChart',
+ metricBase: 'short_code',
+ totalKey: 'clicks_by_short_code',
+ uniqueKey: 'unique_clicks_by_short_code',
+ totalLabel: 'Total Clicks',
+ uniqueLabel: 'Unique Clicks',
+ colors: {
+ total: { bg: 'rgba(139, 92, 246, 0.25)', border: 'rgba(139, 92, 246, 1)' }, // violet (kept as accent)
+ unique: { bg: 'rgba(196, 181, 253, 0.4)', border: 'rgba(196, 181, 253, 1)' } // light violet
+ },
+ defaultMode: 'compare',
+ // Custom tooltip extension replicating previous percentage logic
+ tooltipAfterBody(context, meta) {
+ const index = context[0]?.dataIndex ?? -1;
+ if (index < 0) return '';
+ const label = meta.labels[index];
+ if (label === 'Others') return '';
+ // Use first dataset with 'Clicks' in label (total) for percentage base
+ const clicksDs = meta.datasets.find(d => /Clicks/i.test(d.label));
+ if (!clicksDs) return '';
+ const totalClicks = clicksDs.data.reduce((s, v) => s + v, 0);
+ const value = clicksDs.data[index];
+ const pct = totalClicks > 0 ? ((value / totalClicks) * 100).toFixed(1) : 0;
+ return `\nPercentage: ${pct}%`;
+ }
+ }
+};
+
+/**
+ * Close all modals in the statistics dashboard
+ * Ensures only one modal is open at a time
+ * @param {StatisticsDashboard} dashboard - Reference to dashboard instance for filter handling
+ */
+function closeAllModals(dashboard) {
+ // Close filters dropdown
+ const filtersDropdown = document.querySelector('.filters-dropdown');
+ if (filtersDropdown) {
+ filtersDropdown.classList.remove('show');
+ const filtersBtn = document.querySelector('.filters-btn');
+ if (filtersBtn) {
+ filtersBtn.classList.remove('active');
+ }
+ }
+
+ // Close auto-refresh dropdown
+ const autoRefreshBtn = document.querySelector('.auto-refresh-btn');
+ const autoRefreshDropdown = autoRefreshBtn?.nextElementSibling;
+ if (autoRefreshDropdown) {
+ autoRefreshDropdown.classList.remove('show');
+ if (autoRefreshBtn) {
+ autoRefreshBtn.classList.remove('active');
+ }
+ }
+
+ // Close export dropdown
+ const exportDropdownMenu = document.getElementById('exportDropdownMenu');
+ if (exportDropdownMenu) {
+ exportDropdownMenu.classList.remove('active');
+ const exportBtn = document.querySelector('.export-btn');
+ if (exportBtn) {
+ exportBtn.classList.remove('active');
+ }
+ }
+
+ // Note: Cascade dropdowns are intentionally NOT closed here
+ // They are chart-specific controls and should remain independent
+
+ // Close multi-select dropdowns and apply pending changes
+ document.querySelectorAll('.multi-select-dropdown').forEach(dd => {
+ if (dd.classList.contains('show')) {
+ dd.classList.remove('show');
+ const parentWrapper = dd.parentElement;
+ const trigger = parentWrapper.querySelector('.multi-select-trigger');
+ if (trigger) {
+ trigger.classList.remove('active');
+ }
+ parentWrapper.classList.remove('active');
+
+ // Apply filters when dropdown closes if there are pending changes
+ if (dashboard) {
+ const filterType = parentWrapper.dataset.filter;
+ if (dashboard.pendingChanges && dashboard.pendingChanges.has(filterType)) {
+ dashboard.pendingChanges.delete(filterType);
+ if (dashboard.filterManager) {
+ dashboard.filterManager.notifyChange();
+ }
+ }
+ }
+ }
+ });
+
+ // Close date range picker
+ const dateRangeDropdown = document.getElementById('dateRangeDropdown');
+ if (dateRangeDropdown) {
+ dateRangeDropdown.style.display = 'none';
+ const trigger = document.getElementById('dateRangeTrigger');
+ if (trigger) {
+ trigger.classList.remove('active');
+ }
+ }
+}
+
+class StatisticsDashboard {
+ constructor() {
+ this.charts = new Map();
+ this.currentTimeRange = null;
+ this.startDate = null;
+ this.endDate = null;
+ this.refreshInterval = null;
+ this.autoRefreshInterval = null;
+ this.apiData = null;
+ this.dateRangePicker = null;
+ this.activeRequestController = null; // AbortController for in-flight API requests
+
+ // Filter system
+ this.filterManager = new FilterManager();
+ this.availableOptions = {
+ browser: [],
+ os: [],
+ // device: [], // DISABLED: Reliable device detection not available yet
+ country: [],
+ city: [],
+ referrer: [],
+ short_code: []
+ };
+ this.pendingChanges = new Set(); // Track which categories have pending changes
+ this.currentFilterType = null; // Track which filter type is currently being edited
+
+ this.init();
+ }
+
+ /**
+ * Format time labels based on bucket strategy for better readability
+ * @param {string} timeValue - Raw time value from API
+ * @param {string} bucketStrategy - The bucketing strategy used ('10_minute', 'hourly', 'daily', etc.)
+ * @returns {string} - Formatted human-readable label
+ */
+ formatTimeLabel(timeValue, bucketStrategy) {
+ if (!timeValue) return timeValue;
+
+ try {
+ const date = dayjs(timeValue);
+
+ switch (bucketStrategy) {
+ case '10_minute':
+ case 'hourly':
+ // For hourly: "1:00 AM", "2:00 PM", etc.
+ return date.format('h:mm A');
+
+ case 'daily':
+ // For daily: "Aug 12", "Sep 11", etc.
+ return date.format('MMM D');
+
+ case 'weekly':
+ // For weekly: "Week 32", "Week 33", etc.
+ return `Week ${date.week()}`;
+
+ case 'monthly':
+ // For monthly: "Aug 2025", "Sep 2025", etc.
+ return date.format('MMM YYYY');
+
+ default:
+ // Fallback to daily format
+ return date.format('MMM D');
+ }
+ } catch (error) {
+ console.warn('Error formatting time label:', error);
+ return timeValue; // Return original if parsing fails
+ }
+ }
+
+ getCountryName(countryCode) { return COUNTRY_MAP[countryCode] || countryCode; }
+
+ init() {
+ this.setupDateRangePicker();
+ this.setupEventListeners();
+ this.setupFilterSystem();
+ this.loadDashboardData();
+ this.setupAutoRefresh();
+ this.restoreAutoRefreshSetting();
+ }
+
+ /**
+ * Process chart data to show only top 7 items and group the rest as "Others"
+ * @param {Array} data - Array of data objects with value and label properties
+ * @param {string} valueKey - Key for the numeric value (e.g., 'clicks', 'unique_clicks')
+ * @param {string} labelKey - Key for the label (e.g., 'browser', 'city', 'short_code')
+ * @returns {Object} - Processed data with labels and values arrays
+ */
+ processTopDataWithOthers(data, valueKey, labelKey) {
+ if (!data || data.length === 0) {
+ return { labels: [], values: [] };
+ }
+
+ // Sort data by the value in descending order
+ const sortedData = [...data].sort((a, b) => (b[valueKey] || 0) - (a[valueKey] || 0));
+
+ // If we have TOP_N or fewer items, return all
+ if (sortedData.length <= TOP_N) {
+ return {
+ labels: sortedData.map(item => item[labelKey] || 'Unknown'),
+ values: sortedData.map(item => item[valueKey] || 0)
+ };
+ }
+
+ // Take top N items
+ const topItems = sortedData.slice(0, TOP_N);
+ const remainingItems = sortedData.slice(TOP_N);
+
+ // Calculate "Others" total
+ const othersTotal = remainingItems.reduce((sum, item) => sum + (item[valueKey] || 0), 0);
+
+ // Combine top items with "Others"
+ const labels = [...topItems.map(item => item[labelKey] || 'Unknown'), 'Others'];
+ const values = [...topItems.map(item => item[valueKey] || 0), othersTotal];
+
+ return { labels, values };
+ }
+
+ setupDateRangePicker() {
+ this.dateRangePicker = new DateRangePicker({
+ container: 'dateRangeContainer',
+ onRangeChange: (dateRange) => {
+ this.handleDateRangeChange(dateRange);
+ },
+ defaultRange: 'last-7-days'
+ });
+ }
+
+ handleDateRangeChange(dateRange) {
+ // Convert the date range to API parameters
+ this.startDate = dateRange.start;
+ this.endDate = dateRange.end;
+ this.currentTimeRange = dateRange.range;
+
+ // Reload dashboard data with new range
+ this.loadDashboardData();
+ }
+
+ setupEventListeners() {
+ // Manual refresh button click
+ const refreshBtn = document.querySelector('.refresh-btn');
+ if (refreshBtn) {
+ refreshBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ this.loadDashboardData();
+ });
+ }
+
+ // Auto-refresh dropdown toggle
+ const autoRefreshBtn = document.querySelector('.auto-refresh-btn');
+ if (autoRefreshBtn) {
+ autoRefreshBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const dropdown = autoRefreshBtn.nextElementSibling;
+ if (dropdown && dropdown.classList.contains('dropdown-menu')) {
+ const isOpen = dropdown.classList.contains('show');
+
+ // Close all other modals first
+ closeAllModals(this);
+
+ // Only open if it was previously closed
+ if (!isOpen) {
+ dropdown.classList.add('show');
+ autoRefreshBtn.classList.add('active');
+ }
+ }
+ });
+ }
+
+ // Close dropdown when clicking outside
+ document.addEventListener('click', (e) => {
+ const autoRefreshDropdown = e.target.closest('.auto-refresh-dropdown');
+ if (!autoRefreshDropdown) {
+ const openDropdowns = document.querySelectorAll('.auto-refresh-dropdown .dropdown-menu.show');
+ const autoRefreshBtn = document.querySelector('.auto-refresh-btn');
+ openDropdowns.forEach(dropdown => dropdown.classList.remove('show'));
+ if (autoRefreshBtn) {
+ autoRefreshBtn.classList.remove('active');
+ }
+ }
+ });
+
+ // Table view button controls
+ this.setupTableViewControls();
+
+ // Cascade button controls
+ this.setupCascadeControls();
+ }
+
+ setupFilterSystem() {
+ // Set up filter toggle
+ const filtersBtn = document.querySelector('.filters-btn');
+ const filtersDropdown = document.querySelector('.filters-dropdown');
+
+ if (filtersBtn && filtersDropdown) {
+ filtersBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const isOpen = filtersDropdown.classList.contains('show');
+
+ // Close all other modals first
+ closeAllModals(this);
+
+ if (!isOpen) {
+ // Only open if it was previously closed
+ filtersDropdown.classList.add('show');
+ filtersBtn.classList.add('active');
+ }
+ });
+ }
+
+ // Close dropdown when clicking outside
+ document.addEventListener('click', (e) => {
+ if (!e.target.closest('.filters-dropdown-container')) {
+ const filtersDropdown = document.querySelector('.filters-dropdown');
+ const filtersBtn = document.querySelector('.filters-btn');
+
+ if (filtersDropdown && filtersBtn) {
+ filtersDropdown.classList.remove('show');
+ filtersBtn.classList.remove('active');
+ }
+ }
+ });
+
+ // Set up hierarchical filter navigation
+ this.setupHierarchicalFilters();
+
+ // Set up filter change listeners
+ this.filterManager.onFiltersChanged = () => {
+ this.updateFilterUI();
+ this.loadDashboardData();
+ };
+ }
+
+ setupHierarchicalFilters() {
+ // Set up filter type item clicks
+ const filterTypeItems = document.querySelectorAll('.filter-type-item:not(.clear-all-item)');
+ filterTypeItems.forEach(item => {
+ item.addEventListener('click', (e) => {
+ e.preventDefault();
+ const filterType = item.dataset.filter;
+ this.showFilterValues(filterType);
+ });
+ });
+
+ // Set up clear all button
+ const clearAllItem = document.querySelector('.filter-type-item.clear-all-item');
+ if (clearAllItem) {
+ clearAllItem.addEventListener('click', (e) => {
+ e.preventDefault();
+ this.filterManager.clearAllFilters();
+ this.closeFiltersDropdown();
+ });
+ }
+
+ // Set up back button
+ const backBtn = document.querySelector('.back-btn');
+ if (backBtn) {
+ backBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ // If there are pending changes for current type, apply and go back
+ if (this.currentFilterType && this.pendingChanges.has(this.currentFilterType)) {
+ this.pendingChanges.delete(this.currentFilterType);
+ this.filterManager.notifyChange();
+ }
+ this.showFilterTypes();
+ });
+ }
+
+ // Set up search in values view
+ const valuesSearchInput = document.querySelector('.values-search-input');
+ if (valuesSearchInput) {
+ valuesSearchInput.addEventListener('input', (e) => {
+ if (this.currentFilterType) {
+ this.filterValuesOptions(this.currentFilterType, e.target.value);
+ }
+ });
+ }
+ }
+
+ showFilterValues(filterType) {
+ this.currentFilterType = filterType;
+
+ // Hide main list, show values view
+ const typesList = document.querySelector('.filter-types-list');
+ const valuesView = document.querySelector('.filter-values-view');
+ const backBtn = document.querySelector('.back-btn');
+
+ if (typesList && valuesView) {
+ // exit current view
+ typesList.classList.add('view-exit-active');
+
+ setTimeout(() => {
+ typesList.style.display = 'none';
+ typesList.classList.remove('view-exit-active');
+
+ valuesView.style.display = 'block';
+ valuesView.classList.add('view-enter');
+
+ // force reflow
+ valuesView.offsetHeight;
+ valuesView.classList.add('view-enter-active');
+ valuesView.classList.remove('view-enter');
+
+ setTimeout(() => {
+ valuesView.classList.remove('view-enter-active');
+ }, 200);
+ }, 80);
+
+ // Populate values
+ this.populateFilterValues(filterType);
+
+ // Clear and focus search
+ const searchInput = valuesView.querySelector('.values-search-input');
+ if (searchInput) {
+ searchInput.value = '';
+ setTimeout(() => searchInput.focus(), 100);
+ }
+
+ // Ensure back button shows arrow initially (no pending yet)
+ if (backBtn) {
+ this.setBackButtonApplyState(false);
+ }
+ }
+ }
+
+ showFilterTypes() {
+ // Apply pending changes if any
+ if (this.currentFilterType && this.pendingChanges.has(this.currentFilterType)) {
+ this.pendingChanges.delete(this.currentFilterType);
+ this.filterManager.notifyChange();
+ }
+
+ this.currentFilterType = null;
+
+ // Hide values view, show main list
+ const typesList = document.querySelector('.filter-types-list');
+ const valuesView = document.querySelector('.filter-values-view');
+ const backBtn = document.querySelector('.back-btn');
+
+ if (typesList && valuesView) {
+ valuesView.classList.add('view-exit-active');
+
+ setTimeout(() => {
+ valuesView.style.display = 'none';
+ valuesView.classList.remove('view-exit-active');
+
+ typesList.style.display = 'block';
+ typesList.classList.add('view-enter');
+ typesList.offsetHeight;
+ typesList.classList.add('view-enter-active');
+ typesList.classList.remove('view-enter');
+
+ setTimeout(() => {
+ typesList.classList.remove('view-enter-active');
+ }, 200);
+ }, 80);
+ }
+
+ // Reset back button to arrow
+ if (backBtn) {
+ this.setBackButtonApplyState(false);
+ }
+ }
+
+ setBackButtonApplyState(shouldApply) {
+ const backBtn = document.querySelector('.back-btn');
+ if (!backBtn) return;
+ const icon = backBtn.querySelector('i');
+ if (!icon) return;
+ if (shouldApply) {
+ icon.className = 'ti ti-check';
+ backBtn.title = 'Apply & Back';
+ } else {
+ icon.className = 'ti ti-arrow-left';
+ backBtn.title = 'Back';
+ }
+ }
+
+ closeFiltersDropdown() {
+ const filtersDropdown = document.querySelector('.filters-dropdown');
+ const filtersBtn = document.querySelector('.filters-btn');
+
+ if (filtersDropdown && filtersBtn) {
+ filtersDropdown.classList.remove('show');
+ filtersBtn.classList.remove('active');
+
+ // Reset to main view
+ this.showFilterTypes();
+ }
+ }
+
+ populateFilterValues(filterType) {
+ const valuesList = document.querySelector('.filter-values-list');
+ if (!valuesList) return;
+
+ valuesList.innerHTML = '';
+
+ const options = this.availableOptions[filterType] || [];
+
+ if (options.length === 0) {
+ valuesList.innerHTML = 'No data available
';
+ return;
+ }
+
+ options.forEach(option => {
+ const optionElement = this.createFilterValueElement(filterType, option);
+ valuesList.appendChild(optionElement);
+ });
+ }
+
+ // Unified option element creator (variant: 'panel' for hierarchical view, 'dropdown' for multi-select)
+ createFilterOption(type, option, variant) {
+ const label = document.createElement('label');
+ label.className = 'option-item';
+ const isSelected = this.filterManager.isSelected(type, option.value);
+ label.innerHTML = `
+
+
+ ${this.escapeHtml(option.label)}
+ ${this.formatNumber(option.count)}
+ `;
+ const checkbox = label.querySelector('input[type="checkbox"]');
+ checkbox.addEventListener('change', (e) => {
+ if (e.target.checked) {
+ this.filterManager.addFilter(type, option.value);
+ } else {
+ this.filterManager.removeFilter(type, option.value);
+ }
+ // Mark pending for both variants (applied on close or back)
+ this.pendingChanges.add(type);
+ if (variant === 'panel') {
+ this.setBackButtonApplyState(true);
+ this.updateFilterTypeStatus(type);
+ } else {
+ // Update summary instantly for dropdown variant
+ this.updateFilterSummary(type);
+ }
+ });
+ return label;
+ }
+
+ // Backwards compatibility wrappers (original method names)
+ createFilterValueElement(type, option) { return this.createFilterOption(type, option, 'panel'); }
+
+ filterValuesOptions(type, searchTerm) {
+ const valuesList = document.querySelector('.filter-values-list');
+ if (!valuesList) return;
+
+ const options = valuesList.querySelectorAll('.option-item');
+ const term = searchTerm.toLowerCase();
+
+ options.forEach(option => {
+ const text = option.querySelector('.option-text').textContent.toLowerCase();
+ if (text.includes(term)) {
+ option.style.display = 'flex';
+ } else {
+ option.style.display = 'none';
+ }
+ });
+ }
+
+ updateFilterTypeStatus(type) {
+ const typeItem = document.querySelector(`[data-filter="${type}"]`);
+ const countElement = typeItem?.querySelector('.filter-count');
+
+ if (!countElement) return;
+
+ const activeFilters = this.filterManager.getActiveFilters()[type] || [];
+
+ if (activeFilters.length === 0) {
+ countElement.textContent = 'All';
+ countElement.style.background = 'rgba(255, 255, 255, 0.08)';
+ countElement.style.color = 'rgba(255, 255, 255, 0.6)';
+ } else {
+ countElement.textContent = activeFilters.length.toString();
+ countElement.style.background = 'rgba(124, 58, 237, 0.2)';
+ countElement.style.color = 'rgba(124, 58, 237, 1)';
+ }
+ }
+
+ initializeFilterLoadingState() {
+ FILTER_TYPES.forEach(type => {
+ const optionsList = document.querySelector(`[data-filter="${type}"] .options-list`);
+ if (optionsList) {
+ optionsList.innerHTML = 'Loading options...
';
+ optionsList.classList.add('loading');
+ }
+ });
+ }
+
+ setupMultiSelectDropdowns() {
+ const wrappers = document.querySelectorAll('.multi-select-wrapper');
+
+ wrappers.forEach(wrapper => {
+ const trigger = wrapper.querySelector('.multi-select-trigger');
+ const dropdown = wrapper.querySelector('.multi-select-dropdown');
+ const filterType = wrapper.dataset.filter;
+
+ if (trigger && dropdown) {
+ // Toggle dropdown
+ trigger.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const isOpen = dropdown.classList.contains('show');
+
+ // Close all other modals (including other multi-select dropdowns)
+ closeAllModals(this);
+
+ // Toggle current dropdown - only open if it was previously closed
+ if (!isOpen) {
+ dropdown.classList.add('show');
+ trigger.classList.add('active');
+ wrapper.classList.add('active');
+
+ // Focus search input
+ const searchInput = dropdown.querySelector('.search-input');
+ if (searchInput) {
+ setTimeout(() => searchInput.focus(), 100);
+ }
+ }
+ });
+
+ // Set up search functionality
+ const searchInput = dropdown.querySelector('.search-input');
+ if (searchInput) {
+ searchInput.addEventListener('input', (e) => {
+ this.filterOptions(filterType, e.target.value);
+ });
+ }
+
+ // Set up select all / clear all buttons
+ const selectAllBtn = dropdown.querySelector('.select-all-btn');
+ const clearAllBtn = dropdown.querySelector('.clear-all-btn');
+
+ if (selectAllBtn) {
+ selectAllBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ this.selectAllOptions(filterType);
+ });
+ }
+
+ if (clearAllBtn) {
+ clearAllBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ this.clearAllOptions(filterType);
+ });
+ }
+ }
+ });
+
+ // Close dropdowns when clicking outside
+ document.addEventListener('click', (e) => {
+ if (!e.target.closest('.multi-select-wrapper')) {
+ document.querySelectorAll('.multi-select-dropdown.show').forEach(dropdown => {
+ dropdown.classList.remove('show');
+ const parentWrapper = dropdown.parentElement;
+ const filterType = parentWrapper.dataset.filter;
+
+ parentWrapper.querySelector('.multi-select-trigger').classList.remove('active');
+ parentWrapper.classList.remove('active');
+
+ // Apply filters when dropdown closes if there are changes for this category
+ if (this.pendingChanges.has(filterType)) {
+ this.pendingChanges.delete(filterType);
+ this.filterManager.notifyChange();
+ }
+ });
+ }
+ });
+ }
+
+ setupFilterActions() {
+ const clearAllBtn = document.querySelector('.clear-all-filters-btn');
+
+ if (clearAllBtn) {
+ clearAllBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ this.filterManager.clearAllFilters();
+ });
+ }
+ }
+
+ populateFilterOptions(data) {
+ // Extract available options from API data
+ FILTER_TYPES.forEach(type => {
+ const metricKey = `clicks_by_${type}`;
+ const options = data.metrics?.[metricKey] || [];
+
+ this.availableOptions[type] = options.map(item => {
+ if (type === 'country') {
+ // For countries, use country name as both value and label
+ const countryName = this.getCountryName(item[type]);
+ return {
+ value: countryName,
+ label: countryName,
+ code: item[type], // Keep the code for reference
+ count: item.clicks || item.total_clicks || 0
+ };
+ } else if (type === 'short_code') {
+ // For short_code, get the short_code field from API response
+ const shortCodeValue = item.short_code || item[type];
+ return {
+ value: shortCodeValue,
+ label: shortCodeValue,
+ count: item.clicks || item.total_clicks || 0
+ };
+ } else {
+ return {
+ value: item[type],
+ label: item[type],
+ count: item.clicks || item.total_clicks || 0
+ };
+ }
+ }).filter(option => option.value)
+ .sort((a, b) => b.count - a.count); // Sort by count descending
+ });
+
+ // Update filter type statuses in main list
+ this.updateAllFilterTypeStatuses();
+ }
+
+ updateAllFilterTypeStatuses() {
+ FILTER_TYPES.forEach(type => this.updateFilterTypeStatus(type));
+ }
+
+ renderFilterOptions() {
+ FILTER_TYPES.forEach(type => {
+ const optionsList = document.querySelector(`[data-filter="${type}"] .options-list`);
+
+ if (optionsList) {
+ optionsList.innerHTML = '';
+ optionsList.classList.remove('loading', 'empty');
+
+ const options = this.availableOptions[type] || [];
+
+ if (options.length === 0) {
+ optionsList.innerHTML = 'No data available
';
+ optionsList.classList.add('empty');
+ return;
+ }
+
+ options.forEach(option => {
+ const optionElement = this.createOptionElement(type, option);
+ optionsList.appendChild(optionElement);
+ });
+ } else {
+ console.warn(`Options list not found for ${type}`);
+ }
+ });
+ }
+
+ createOptionElement(type, option) { return this.createFilterOption(type, option, 'dropdown'); }
+
+ filterOptions(type, searchTerm) {
+ const optionsList = document.querySelector(`[data-filter="${type}"] .options-list`);
+ if (!optionsList) return;
+
+ const options = optionsList.querySelectorAll('.option-item');
+ const term = searchTerm.toLowerCase();
+
+ options.forEach(option => {
+ const text = option.querySelector('.option-text').textContent.toLowerCase();
+ if (text.includes(term)) {
+ option.style.display = 'flex';
+ } else {
+ option.style.display = 'none';
+ }
+ });
+ }
+
+ updateFilterUI() {
+ // Update active filter count
+ const totalActiveFilters = this.filterManager.getTotalActiveFilters();
+ const countElement = document.querySelector('.active-filters-count');
+
+ if (countElement) {
+ if (totalActiveFilters > 0) {
+ countElement.textContent = totalActiveFilters;
+ countElement.style.display = 'inline-block';
+ } else {
+ countElement.style.display = 'none';
+ }
+ }
+
+ // Update filter summaries
+ FILTER_TYPES.forEach(type => {
+ this.updateFilterSummary(type);
+ this.updateChartFilterIndicator(type);
+ });
+
+ // Update clear all button state
+ const clearAllBtn = document.querySelector('.clear-all-filters-btn');
+ if (clearAllBtn) {
+ clearAllBtn.disabled = totalActiveFilters === 0;
+ }
+ }
+
+ /**
+ * Update visual indicator on charts when filters are active
+ * @param {string} filterType - Type of filter to check
+ */
+ updateChartFilterIndicator(filterType) {
+ const activeFilters = this.filterManager.getActiveFilters()[filterType] || [];
+ const hasActiveFilter = activeFilters.length > 0;
+
+ // Map filter types to chart container selectors
+ const chartMap = {
+ browser: '#browserChart',
+ os: '#osChart',
+ country: '#countryChart',
+ city: '#cityChart',
+ referrer: '#referrerChart',
+ key: '#keyChart'
+ };
+
+ const chartSelector = chartMap[filterType];
+ if (chartSelector) {
+ const chartContainer = document.querySelector(chartSelector)?.closest('.chart-container');
+ if (chartContainer) {
+ chartContainer.setAttribute('data-has-active-filter', hasActiveFilter.toString());
+ }
+ }
+ }
+
+ updateFilterSummary(type) {
+ const trigger = document.querySelector(`[data-filter="${type}"] .multi-select-trigger`);
+ const summary = trigger?.querySelector('.selected-summary');
+
+ if (!summary) return;
+
+ const selectedFilters = this.filterManager.getActiveFilters()[type] || [];
+ const typeLabels = {
+ browser: 'browsers',
+ os: 'systems',
+ // device: 'devices', // DISABLED: Reliable device detection not available yet
+ country: 'countries',
+ city: 'cities',
+ referrer: 'referrers',
+ short_code: 'short URLs'
+ };
+
+ if (selectedFilters.length === 0) {
+ summary.textContent = `All ${typeLabels[type]}`;
+ trigger.classList.remove('active');
+ } else if (selectedFilters.length === 1) {
+ const option = this.availableOptions[type].find(opt => opt.value === selectedFilters[0]);
+ summary.textContent = option ? option.label : selectedFilters[0];
+ trigger.classList.add('active');
+ } else if (selectedFilters.length <= 3) {
+ const labels = selectedFilters.map(value => {
+ const option = this.availableOptions[type].find(opt => opt.value === value);
+ return option ? option.label : value;
+ });
+ summary.textContent = labels.join(', ');
+ trigger.classList.add('active');
+ } else {
+ const firstTwo = selectedFilters.slice(0, 2).map(value => {
+ const option = this.availableOptions[type].find(opt => opt.value === value);
+ return option ? option.label : value;
+ });
+ summary.textContent = `${firstTwo.join(', ')} +${selectedFilters.length - 2}`;
+ trigger.classList.add('active');
+ }
+ }
+
+ setupCascadeControls() {
+ // Handle cascade button clicks
+ document.querySelectorAll('.cascade-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ // Don't proceed if button is disabled
+ if (btn.disabled) {
+ e.preventDefault();
+ e.stopPropagation();
+ return;
+ }
+
+ e.stopPropagation();
+ const dropdown = btn.nextElementSibling;
+ const isOpen = dropdown.classList.contains('show');
+
+ // Close all other modals first
+ closeAllModals(this);
+
+ // Close other cascade dropdowns
+ document.querySelectorAll('.cascade-dropdown').forEach(dd => {
+ if (dd !== dropdown) dd.classList.remove('show');
+ });
+
+ // Toggle current dropdown
+ if (isOpen) {
+ dropdown.classList.remove('show');
+ } else {
+ dropdown.classList.add('show');
+ }
+ });
+ });
+
+ // Handle cascade option selection
+ document.querySelectorAll('.cascade-option').forEach(option => {
+ option.addEventListener('click', (e) => {
+ const cascadeSelect = option.closest('.cascade-select');
+
+ // Don't proceed if cascade select is disabled
+ if (cascadeSelect && cascadeSelect.style.pointerEvents === 'none') {
+ e.preventDefault();
+ e.stopPropagation();
+ return;
+ }
+
+ const value = option.dataset.value;
+ const chartType = cascadeSelect.dataset.chart;
+
+ // Update active states
+ cascadeSelect.querySelectorAll('.cascade-option').forEach(opt => {
+ opt.classList.remove('active');
+ });
+ option.classList.add('active');
+
+ // Update main button
+ const mainBtn = cascadeSelect.querySelector('.cascade-btn');
+ mainBtn.dataset.value = value;
+ mainBtn.querySelector('i').className = option.querySelector('i').className;
+ mainBtn.title = option.querySelector('span').textContent;
+
+ // Close dropdown
+ cascadeSelect.querySelector('.cascade-dropdown').classList.remove('show');
+
+ // Update chart
+ this.updateChartByType(chartType, value);
+ });
+ });
+
+ // Close dropdowns when clicking outside
+ document.addEventListener('click', () => {
+ document.querySelectorAll('.cascade-dropdown').forEach(dropdown => {
+ dropdown.classList.remove('show');
+ });
+ });
+ }
+
+ setupTableViewControls() {
+ // Handle table view button clicks
+ document.querySelectorAll('.table-view-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const chartType = btn.dataset.chart;
+ this.toggleTableView(chartType, btn);
+ });
+ });
+ }
+
+ toggleTableView(chartType, btn) {
+ const chartContainer = document.getElementById(chartType);
+ const tableContainer = document.getElementById(chartType.replace('Chart', 'Table'));
+ const cascadeSelect = document.querySelector(`[data-chart="${chartType}"].cascade-select`);
+
+ if (!chartContainer || !tableContainer) return;
+
+ const isTableVisible = tableContainer.style.display !== 'none';
+
+ if (isTableVisible) {
+ // Switch to chart view with fade transition
+ this.fadeTransition(tableContainer, chartContainer, () => {
+ btn.classList.remove('active');
+ btn.title = 'Table View';
+
+ // Enable cascade selector
+ if (cascadeSelect) {
+ cascadeSelect.style.pointerEvents = 'auto';
+ cascadeSelect.style.opacity = '1';
+ const cascadeBtn = cascadeSelect.querySelector('.cascade-btn');
+ if (cascadeBtn) {
+ cascadeBtn.disabled = false;
+ }
+ }
+
+ });
+ } else {
+ // Switch to table view with fade transition
+ this.fadeTransition(chartContainer, tableContainer, () => {
+ btn.classList.add('active');
+ btn.title = 'Chart View';
+
+ // Disable cascade selector
+ if (cascadeSelect) {
+ cascadeSelect.style.pointerEvents = 'none';
+ cascadeSelect.style.opacity = '0.5';
+ const cascadeBtn = cascadeSelect.querySelector('.cascade-btn');
+ if (cascadeBtn) {
+ cascadeBtn.disabled = true;
+ }
+ }
+
+ // Update table data
+ this.updateTableData(chartType);
+ });
+ }
+ }
+
+ fadeTransition(fromElement, toElement, callback) {
+ // Add exit animation to current element
+ fromElement.classList.add('chart-view-exit-active');
+
+ setTimeout(() => {
+ // Hide the outgoing element and show the incoming element
+ fromElement.style.display = 'none';
+ fromElement.classList.remove('chart-view-exit-active');
+
+ toElement.style.display = 'block';
+ toElement.classList.add('table-view-enter');
+
+ // Force reflow to ensure the enter class is applied
+ toElement.offsetHeight;
+
+ // Add enter animation
+ toElement.classList.add('table-view-enter-active');
+ toElement.classList.remove('table-view-enter');
+
+ // Execute callback
+ if (callback) callback();
+
+ // Clean up transition classes
+ setTimeout(() => {
+ toElement.classList.remove('table-view-enter-active');
+ }, 300);
+
+ }, 150); // Half of the transition duration for overlap effect
+ }
+
+ updateTableData(chartType) {
+ if (!this.apiData) return;
+
+ const tableId = chartType.replace('Chart', 'Table');
+ const tableBodyId = tableId + 'Body';
+ const tableBody = document.getElementById(tableBodyId);
+
+ if (!tableBody) return;
+
+ // Clear existing data
+ tableBody.innerHTML = '';
+
+ // Get data based on chart type
+ let data = [];
+ let dataKey = '';
+
+ switch (chartType) {
+ case 'timeSeriesChart':
+ data = this.getTimeSeriesTableData();
+ break;
+ case 'keyChart':
+ data = this.getKeyTableData();
+ break;
+ case 'cityChart':
+ data = this.getCityTableData();
+ break;
+ case 'browserChart':
+ data = this.getBrowserTableData();
+ break;
+ case 'osChart':
+ data = this.getOsTableData();
+ break;
+ case 'referrerChart':
+ data = this.getReferrerTableData();
+ break;
+ case 'countryChart':
+ data = this.getCountryTableData();
+ break;
+ }
+
+ // Populate table
+ if (data.length === 0) {
+ tableBody.innerHTML = 'No data available for the selected time range
';
+ return;
+ }
+
+ data.forEach(item => {
+ const row = document.createElement('div');
+ row.className = 'table-row';
+ row.innerHTML = `
+ ${this.escapeHtml(item.label)}
+ ${this.formatNumber(item.clicks)}
+ ${this.formatNumber(item.unique_clicks)}
+ `;
+ tableBody.appendChild(row);
+ });
+ }
+
+ getTimeSeriesTableData() {
+ const clicksByTime = this.apiData.metrics?.clicks_by_time || [];
+ const uniqueClicksByTime = this.apiData.metrics?.unique_clicks_by_time || [];
+
+ return clicksByTime.map((item, index) => ({
+ label: item.time || item.date || 'Unknown',
+ clicks: item.clicks || item.value || 0,
+ unique_clicks: uniqueClicksByTime[index]?.unique_clicks || uniqueClicksByTime[index]?.value || 0
+ }));
+ }
+
+ getKeyTableData() {
+ const clicksByShortCode = this.apiData.metrics?.clicks_by_short_code || [];
+ const uniqueClicksByShortCode = this.apiData.metrics?.unique_clicks_by_short_code || [];
+
+ // Create a map for quick lookup of unique clicks by short_code
+ const uniqueClicksMap = new Map();
+ uniqueClicksByShortCode.forEach(item => {
+ uniqueClicksMap.set(item.short_code, item.unique_clicks || item.value || 0);
+ });
+
+ // Return ALL data for tables (not limited to top 7)
+ return clicksByShortCode.map(item => ({
+ label: item.short_code || 'Unknown',
+ clicks: item.clicks || item.value || 0,
+ unique_clicks: uniqueClicksMap.get(item.short_code) || 0
+ }));
+ }
+
+ getCityTableData() {
+ const clicksByCity = this.apiData.metrics?.clicks_by_city || [];
+ const uniqueClicksByCity = this.apiData.metrics?.unique_clicks_by_city || [];
+
+ // Return ALL data for tables (not limited to top 7)
+ return clicksByCity.map((item, index) => ({
+ label: item.city || 'Unknown',
+ clicks: item.clicks || item.value || 0,
+ unique_clicks: uniqueClicksByCity[index]?.unique_clicks || uniqueClicksByCity[index]?.value || 0
+ }));
+ }
+
+ // Generic table builders for categorical metrics
+ buildTableData(totalKey, uniqueKey, { labelField, fallback = 'Unknown', labelTransform } = {}) {
+ const totalArr = this.apiData.metrics?.[totalKey] || [];
+ const uniqueArr = this.apiData.metrics?.[uniqueKey] || [];
+ return totalArr.map((item, index) => {
+ const raw = item[labelField];
+ const labelBase = raw || fallback;
+ const label = labelTransform ? labelTransform(labelBase, item) : labelBase;
+ const uniqueVal = uniqueArr[index]?.unique_clicks || uniqueArr[index]?.value || 0;
+ return {
+ label,
+ clicks: item.clicks || item.value || 0,
+ unique_clicks: uniqueVal
+ };
+ });
+ }
+
+ getBrowserTableData() { return this.buildTableData('clicks_by_browser', 'unique_clicks_by_browser', { labelField: 'browser' }); }
+ getOsTableData() { return this.buildTableData('clicks_by_os', 'unique_clicks_by_os', { labelField: 'os' }); }
+ getReferrerTableData() { return this.buildTableData('clicks_by_referrer', 'unique_clicks_by_referrer', { labelField: 'referrer', fallback: 'Direct' }); }
+ getCountryTableData() { return this.buildTableData('clicks_by_country', 'unique_clicks_by_country', { labelField: 'country', labelTransform: (label, item) => this.getCountryName(item.country) }); }
+
+ escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ /**
+ * Add click handler to chart for interactive filtering
+ * @param {Chart} chart - Chart.js chart instance
+ * @param {string} filterType - Type of filter (browser, os, country, etc.)
+ */
+ addChartClickHandler(chart, filterType) {
+ if (!chart || !chart.canvas) return;
+
+ chart.canvas.onclick = (event) => {
+ const activePoints = chart.getElementsAtEventForMode(event, 'nearest', { intersect: true }, false);
+
+ if (activePoints.length > 0) {
+ const firstPoint = activePoints[0];
+ const label = chart.data.labels[firstPoint.index];
+
+ // Don't allow filtering on "Others" category
+ if (label === 'Others') {
+ return;
+ }
+
+ // Toggle filter
+ this.toggleChartFilter(filterType, label);
+ }
+ };
+
+ // Change cursor to pointer when hovering over chart elements
+ chart.canvas.onmousemove = (event) => {
+ const activePoints = chart.getElementsAtEventForMode(event, 'nearest', { intersect: true }, false);
+
+ if (activePoints.length > 0) {
+ const firstPoint = activePoints[0];
+ const label = chart.data.labels[firstPoint.index];
+ chart.canvas.style.cursor = label === 'Others' ? 'default' : 'pointer';
+ } else {
+ chart.canvas.style.cursor = 'default';
+ }
+ };
+
+ // Store chart reference for potential updates
+ chart._filterType = filterType;
+ }
+
+ /**
+ * Toggle filter when chart element is clicked
+ * @param {string} filterType - Type of filter
+ * @param {string} value - Value to toggle
+ */
+ toggleChartFilter(filterType, value) {
+ // Check if filter is currently active
+ const isActive = this.filterManager.isSelected(filterType, value);
+
+ if (isActive) {
+ // Remove filter
+ this.filterManager.removeFilter(filterType, value);
+ } else {
+ // Add filter
+ this.filterManager.addFilter(filterType, value);
+ }
+
+ // Update UI and trigger data refresh
+ this.updateFilterUI();
+ this.filterManager.notifyChange();
+ }
+
+ /**
+ * Add click handler to AnyChart map for country filtering
+ * @param {anychart.Map} map - AnyChart map instance
+ */
+ addMapClickHandler(map) {
+ if (!map) return;
+
+ map.listen('pointClick', (e) => {
+ // Get the clicked point data
+ const point = e.point;
+
+ // Try to get country code from the point's data
+ let countryCode = null;
+
+ try {
+ // AnyChart map points have different API - try these methods
+ countryCode = point.get('id') ||
+ point.get('iso_a2') ||
+ point.get('code');
+
+ if (countryCode) {
+ const countryName = this.getCountryName(countryCode);
+ if (countryName && countryName !== 'Unknown') {
+ this.toggleChartFilter('country', countryName);
+ }
+ } else {
+ console.warn('Could not extract country code from map click');
+ }
+ } catch (error) {
+ console.error('Error in map click handler:', error);
+ }
+ });
+
+ // Simplified cursor handling - directly set on container
+ map.listen('pointMouseOver', (e) => {
+ try {
+ const container = document.getElementById('countryChart');
+ if (container) {
+ container.style.cursor = 'pointer';
+ }
+ } catch (error) {
+ console.warn('Error setting cursor on mouse over:', error);
+ }
+ });
+
+ map.listen('pointMouseOut', (e) => {
+ try {
+ const container = document.getElementById('countryChart');
+ if (container) {
+ container.style.cursor = 'default';
+ }
+ } catch (error) {
+ console.warn('Error setting cursor on mouse out:', error);
+ }
+ });
+ }
+
+ updateChartByType(chartType, value) {
+ if (!this.apiData) return;
+
+ switch (chartType) {
+ case 'timeSeriesChart':
+ this.updateTimeSeriesChart(this.apiData, value);
+ break;
+ case 'keyChart':
+ this.updateKeyChart(this.apiData, value);
+ break;
+ case 'cityChart':
+ this.updateCityChart(this.apiData, value);
+ break;
+ case 'browserChart':
+ this.updateBrowserChart(this.apiData, value);
+ break;
+ case 'osChart':
+ this.updateOsChart(this.apiData, value);
+ break;
+ case 'referrerChart':
+ this.updateReferrerChart(this.apiData, value);
+ break;
+ case 'countryChart':
+ this.updateCountryChart(this.apiData, value);
+ break;
+ }
+
+ // Update table data if table is currently visible
+ const tableBtn = document.querySelector(`[data-chart="${chartType}"].table-view-btn`);
+ if (tableBtn && tableBtn.classList.contains('active')) {
+ this.updateTableData(chartType);
+ }
+ }
+
+ async loadDashboardData() {
+ try {
+ // Abort prior request if still in-flight
+ if (this.activeRequestController) {
+ this.activeRequestController.abort();
+ }
+ this.activeRequestController = new AbortController();
+ const { signal } = this.activeRequestController;
+ const params = new URLSearchParams({
+ scope: 'all',
+ group_by: 'time,browser,os,country,city,referrer,short_code',
+ metrics: 'clicks,unique_clicks'
+ });
+
+ // Always send specific start_date and end_date in UTC format
+ // Use the date range picker's built-in functionality to get current range
+ let startDate, endDate;
+
+ if (this.startDate && this.endDate) {
+ // Use provided datetime range
+ startDate = new Date(this.startDate);
+ endDate = new Date(this.endDate);
+ } else {
+ // Get current range from date range picker (handles both relative and custom ranges)
+ const currentRange = this.dateRangePicker.getCurrentRange();
+ startDate = new Date(currentRange.start);
+ endDate = new Date(currentRange.end);
+ }
+
+ // Send full ISO datetime strings in UTC (preserving time component)
+ params.append('start_date', startDate.toISOString());
+ params.append('end_date', endDate.toISOString());
+
+ // Auto-detect and send user's timezone for output formatting
+ const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ params.append('timezone', userTimezone);
+
+ // Add active filters to the request
+ const activeFilters = this.filterManager.getActiveFilters();
+ Object.keys(activeFilters).forEach(filterType => {
+ const values = activeFilters[filterType];
+ if (values && values.length > 0) {
+ params.append(filterType, values.join(','));
+ }
+ });
+
+ const response = await fetch(`/api/v1/stats?${params.toString()}`, {
+ method: 'GET',
+ credentials: 'include', // Include cookies for authentication
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ signal
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ this.apiData = await response.json();
+
+ // Always populate filter options from API data
+ this.populateFilterOptions(this.apiData);
+
+ this.updateDashboard(this.apiData);
+
+ } catch (error) {
+ if (error.name === 'AbortError') {
+ // Silently ignore aborted requests
+ return;
+ }
+ console.error('Error loading dashboard data:', error);
+ this.showError('Failed to load dashboard data. Please try again.');
+ }
+ }
+
+ updateDashboard(data) {
+ this.updateStatCards(data);
+ this.updateCharts(data);
+ }
+
+ updateStatCards(data) {
+ // Get total metrics from summary
+ const totalClicks = data.summary?.total_clicks || 0;
+ const uniqueClicks = data.summary?.unique_clicks || 0;
+ const uniqueRate = totalClicks > 0 ? ((uniqueClicks / totalClicks) * 100).toFixed(1) : 0;
+ const redirectionTime = data.summary?.avg_redirection_time || 0;
+
+ // Update stat values
+ document.getElementById('totalClicks').textContent = this.formatNumber(totalClicks);
+ document.getElementById('uniqueClicks').textContent = this.formatNumber(uniqueClicks);
+ document.getElementById('clickRate').textContent = `${uniqueRate}%`;
+ document.getElementById('redirectionTime').textContent = this.formatNumber(redirectionTime) + ' ms';
+ }
+
+ updateChangeIndicator(elementId, changeValue) {
+ const element = document.getElementById(elementId);
+ const isPositive = changeValue > 0;
+ const isNegative = changeValue < 0;
+
+ element.textContent = `${isPositive ? '+' : ''}${changeValue}${elementId.includes('Rate') ? '%' : ''}`;
+ element.className = `stat-change ${isPositive ? 'positive' : isNegative ? 'negative' : 'neutral'}`;
+ }
+
+ updateCharts(data) {
+ // Time series & country handled separately; categorical charts via config map
+ this.updateTimeSeriesChart(data);
+ // Generic categorical charts
+ Object.keys(CHART_CONFIGS).forEach(type => {
+ // Map type key to its specific update wrapper for backwards compatibility of external calls
+ switch (type) {
+ case 'browser': this.updateBrowserChart(data); break;
+ case 'os': this.updateOsChart(data); break;
+ case 'referrer': this.updateReferrerChart(data); break;
+ case 'city': this.updateCityChart(data); break;
+ case 'short_code': this.updateKeyChart(data); break;
+ }
+ });
+ this.updateCountryChart(data);
+
+ // Update any visible tables
+ document.querySelectorAll('.table-view-btn.active').forEach(btn => {
+ const chartType = btn.dataset.chart;
+ this.updateTableData(chartType);
+ });
+ }
+
+ // Generic categorical chart builder (browser/os/referrer/city/key)
+ updateCategoricalChart(type, data, option = null) {
+ const cfg = CHART_CONFIGS[type];
+ if (!cfg) {
+ console.warn(`No chart config for type: ${type}`);
+ return;
+ }
+ const canvas = document.getElementById(cfg.id);
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+
+ // Destroy existing chart instance if present
+ if (this.charts.has(type)) {
+ try { this.charts.get(type).destroy(); } catch (_) { /* ignore */ }
+ }
+
+ const totalArr = data.metrics?.[cfg.totalKey] || [];
+ const uniqueArr = data.metrics?.[cfg.uniqueKey] || [];
+ // Determine mode precedence: explicit arg > cascade button state > default
+ const cascadeVal = document.querySelector(`[data-chart="${cfg.id}"] .cascade-btn`)?.dataset.value;
+ const mode = option || cascadeVal || cfg.defaultMode;
+
+ const datasets = [];
+ let labelsRef = []; // labels chosen (mirrors prior implementation behaviour)
+
+ // Helper to push dataset
+ const pushDs = (label, values, color) => {
+ datasets.push({
+ label,
+ data: values,
+ backgroundColor: color.bg,
+ borderColor: color.border,
+ borderWidth: 2,
+ borderRadius: 20,
+ });
+ };
+
+ if (mode === 'total' || mode === 'compare') {
+ const processed = this.processTopDataWithOthers(totalArr, 'clicks', cfg.metricBase);
+ labelsRef = processed.labels;
+ pushDs(cfg.totalLabel, processed.values, cfg.colors.total);
+ }
+ if (mode === 'unique' || mode === 'compare') {
+ if (labelsRef.length === 0) {
+ const processedUnique = this.processTopDataWithOthers(uniqueArr, 'unique_clicks', cfg.metricBase);
+ labelsRef = processedUnique.labels;
+ pushDs(cfg.uniqueLabel, processedUnique.values, cfg.colors.unique);
+ } else {
+ const aggregated = new Map();
+ uniqueArr.forEach(item => {
+ const key = item[cfg.metricBase] ?? 'Unknown';
+ const value = item.unique_clicks ?? item.value ?? 0;
+ aggregated.set(key, (aggregated.get(key) ?? 0) + value);
+ });
+ const alignedValues = labelsRef.map(label => {
+ if (label === 'Others') {
+ let remainder = 0;
+ aggregated.forEach((val, key) => {
+ if (!labelsRef.includes(key)) {
+ remainder += val;
+ }
+ });
+ return remainder;
+ }
+ return aggregated.get(label) ?? 0;
+ });
+ pushDs(cfg.uniqueLabel, alignedValues, cfg.colors.unique);
+ }
+ }
+
+ const chartOptions = {
+ responsive: true,
+ maintainAspectRatio: false,
+ indexAxis: 'y',
+ scales: {
+ x: {
+ ticks: { color: '#fff' },
+ grid: { color: 'rgba(255, 255, 255, 0.1)' }
+ },
+ y: {
+ stacked: mode === 'compare',
+ ticks: { color: '#fff' },
+ grid: { display: false }
+ },
+ },
+ plugins: {
+ legend: {
+ labels: { color: '#fff', boxWidth: 12, boxHeight: 12, useBorderRadius: true, borderRadius: 2, padding: 20 },
+ position: 'bottom',
+ align: 'start',
+ },
+ tooltip: {
+ backgroundColor: 'rgba(0, 0, 0, 0.8)',
+ titleColor: '#ffffff',
+ bodyColor: '#ffffff',
+ }
+ }
+ };
+
+ if (cfg.tooltipAfterBody) {
+ chartOptions.plugins.tooltip.callbacks = chartOptions.plugins.tooltip.callbacks || {};
+ chartOptions.plugins.tooltip.callbacks.afterBody = (context) => cfg.tooltipAfterBody(context, { labels: labelsRef, datasets });
+ }
+
+ const chart = new Chart(ctx, {
+ type: 'bar',
+ data: { labels: labelsRef, datasets },
+ options: chartOptions,
+ });
+
+ this.addChartClickHandler(chart, cfg.metricBase); // enable interactive filtering
+ this.charts.set(type, chart);
+ }
+
+ updateTimeSeriesChart(data, option = null) {
+ const canvas = document.getElementById('timeSeriesChart');
+ const ctx = canvas.getContext('2d');
+
+ // Destroy existing chart
+ if (this.charts.has('timeSeries')) {
+ this.charts.get('timeSeries').destroy();
+ }
+
+ // Extract time series data from metrics
+ const clicksByTime = data.metrics?.clicks_by_time || [];
+ const uniqueClicksByTime = data.metrics?.unique_clicks_by_time || [];
+
+ // Get bucket strategy from API response for label formatting
+ const bucketStrategy = data.time_bucket_info?.strategy || 'daily';
+
+ // Convert to chart format with human-readable labels
+ const rawLabels = clicksByTime.map(item => item.time || item.date);
+ const labels = rawLabels.map(label => this.formatTimeLabel(label, bucketStrategy));
+ const clicksData = clicksByTime.map(item => item.clicks !== undefined ? item.clicks : (item.value || 0));
+ const uniqueClicksData = uniqueClicksByTime.map(item => item.unique_clicks !== undefined ? item.unique_clicks : (item.value || 0));
+
+ // Build datasets (restored after refactor consolidation)
+ const datasets = [];
+ const dataOption = option || document.querySelector('[data-chart="timeSeriesChart"] .cascade-btn')?.dataset.value || 'compare';
+ if (dataOption === 'total' || dataOption === 'compare') {
+ datasets.push({
+ label: 'Total Clicks',
+ data: clicksData,
+ fill: 'start',
+ backgroundColor: 'rgba(139, 92, 246, 0.15)',
+ borderColor: 'rgba(139, 92, 246, 1)',
+ borderWidth: 2,
+ tension: 0,
+ });
+ }
+ if (dataOption === 'unique' || dataOption === 'compare') {
+ datasets.push({
+ label: 'Unique Clicks',
+ data: uniqueClicksData,
+ fill: 'start',
+ backgroundColor: 'rgba(59, 130, 246, 0.2)',
+ borderColor: 'rgba(59, 130, 246, 1)',
+ borderWidth: 2,
+ tension: 0,
+ });
+ }
+
+ const baseLegend = { labels: { color: '#fff', boxWidth: 12, boxHeight: 12, useBorderRadius: true, borderRadius: 2, padding: 20 }, position: 'bottom', align: 'start' };
+ const baseTooltip = {
+ backgroundColor: 'rgba(0,0,0,0.8)', titleColor: '#ffffff', bodyColor: '#ffffff', callbacks: {
+ title: (context) => {
+ const index = context[0].dataIndex;
+ const formattedLabel = labels[index];
+ const originalTime = rawLabels[index];
+ if (bucketStrategy === 'hourly' || bucketStrategy === '10_minute') {
+ const date = dayjs(originalTime);
+ return `${date.format('MMM D, YYYY')} at ${formattedLabel}`;
+ } else if (bucketStrategy === 'daily') {
+ const date = dayjs(originalTime);
+ return `${formattedLabel}, ${date.format('YYYY')}`;
+ }
+ return formattedLabel;
+ }
+ }
+ };
+ const chart = new Chart(ctx, { type: 'line', data: { labels, datasets }, options: { responsive: true, maintainAspectRatio: false, pointStyle: false, scales: { x: { ticks: { color: '#fff', maxTicksLimit: 8 }, grid: { color: 'rgba(255,255,255,0.1)' } }, y: { beginAtZero: true, ticks: { color: '#fff', maxTicksLimit: 10 }, grid: { color: 'rgba(255,255,255,0.1)' } } }, plugins: { legend: baseLegend, tooltip: baseTooltip } } });
+
+ this.charts.set('timeSeries', chart);
+ }
+
+ updateBrowserChart(data, option = null) { this.updateCategoricalChart('browser', data, option); }
+
+ updateOsChart(data, option = null) { this.updateCategoricalChart('os', data, option); }
+
+ updateReferrerChart(data, option = null) { this.updateCategoricalChart('referrer', data, option); }
+
+ updateCountryChart(data, option = null) {
+ const countryChartContainer = document.getElementById('countryChart');
+ countryChartContainer.innerHTML = ''; // Clear previous map
+
+ // Extract country data from metrics
+ const clicksByCountry = data.metrics?.clicks_by_country || [];
+ const uniqueClicksByCountry = data.metrics?.unique_clicks_by_country || [];
+
+ const dataOption = option || document.querySelector('[data-chart="countryChart"] .cascade-btn')?.dataset.value || 'total';
+ const countryData = dataOption === 'unique' ? uniqueClicksByCountry : clicksByCountry;
+
+ // Convert to AnyChart format: array of {id: 'country_code', value: clicks}
+ const mapData = countryData.map(item => ({
+ id: item.country, // Use country code directly (now stored as codes)
+ value: item.clicks || item.unique_clicks
+ }));
+
+ // Create AnyChart map
+ var dataSet = anychart.data.set(mapData);
+ var map = anychart.map();
+
+ map.geoData("anychart.maps.world");
+
+ var series = map.choropleth(dataSet);
+
+ series.colorScale(
+ anychart.scales.linearColor("#2d1b3d", "#8b5cf6", "#a855f7", "#c084fc")
+ );
+
+ series.stroke('#8b5cf6', 1);
+
+ series.hovered().fill(function (d) {
+ return anychart.color.darken(d.sourceColor, 0.1);
+ });
+
+ series.tooltip().format(function (e) {
+ return "Clicks: " + e.getData("value") + " ";
+ });
+
+ var title = map.title();
+ title.enabled(false);
+
+ map.tooltip().useHtml(true);
+
+ map.colorRange().enabled(true);
+ map.colorRange().orientation('bottom');
+ map.colorRange().labels({ 'fontSize': 13, 'fontColor': 'white' });
+ map.colorRange().stroke('', 0.5, '5 2', 'round');
+
+ var grids = map.grids();
+ grids.enabled(true);
+ grids.stroke("rgba(255, 255, 255, 0.1)", 0.5, "10 2", "round");
+
+ var marker = map.colorRange().marker();
+ marker.size(7);
+
+ map.interactivity().zoomOnMouseWheel(true);
+ map.interactivity().keyboardZoomAndMove(true);
+ map.interactivity().zoomOnDoubleClick(true);
+
+ var zoomController = anychart.ui.zoom();
+ zoomController.target(map);
+ zoomController.render();
+
+ map.container("countryChart");
+ map.contextMenu(true);
+
+ map.background().fill("rgba(255, 255, 255, 0)");
+
+ map.contextMenu().itemsFormatter(function (items) {
+ delete items["full-screen-separator"];
+ delete items["about"];
+ delete items["share-with"];
+ delete items["print-chart"];
+ return items;
+ });
+
+ map.draw();
+
+ // Add click handler for interactive filtering
+ this.addMapClickHandler(map);
+
+ // Store map instance for cleanup
+ this.charts.set('country', map);
+ }
+
+ updateCityChart(data, option = null) { this.updateCategoricalChart('city', data, option); }
+
+ updateKeyChart(data, option = null) { this.updateCategoricalChart('short_code', data, option); }
+
+ formatNumber(num) {
+ if (num >= 1000000) {
+ return (num / 1000000).toFixed(1) + 'M';
+ } else if (num >= 1000) {
+ return (num / 1000).toFixed(1) + 'K';
+ }
+ return num.toString();
+ }
+
+ setupAutoRefresh() {
+ // Auto-refresh dropdown items
+ const refreshDropdown = document.querySelector('.auto-refresh-dropdown .dropdown-menu');
+ if (refreshDropdown) {
+ refreshDropdown.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const item = e.target.closest('.dropdown-item');
+ if (item && !item.classList.contains('disabled')) {
+ const interval = parseInt(item.dataset.interval);
+ this.setAutoRefreshInterval(interval);
+
+ // Update button text
+ const autoRefreshBtn = document.querySelector('.auto-refresh-btn');
+ const intervalText = item.textContent.trim();
+ autoRefreshBtn.innerHTML = `${intervalText} `;
+
+ // Close dropdown
+ const dropdown = refreshDropdown;
+ dropdown.classList.remove('show');
+ }
+ });
+ }
+ }
+
+ setAutoRefreshInterval(seconds) {
+ // Clear existing interval
+ if (this.autoRefreshInterval) {
+ clearInterval(this.autoRefreshInterval);
+ this.autoRefreshInterval = null;
+ }
+
+ // Save to localStorage
+ if (seconds > 0) {
+ localStorage.setItem('stats-auto-refresh-interval', seconds.toString());
+ } else {
+ localStorage.removeItem('stats-auto-refresh-interval');
+ }
+
+ // Set new interval if seconds > 0
+ if (seconds > 0) {
+ const milliseconds = seconds * 1000;
+ this.autoRefreshInterval = setInterval(() => {
+ this.loadDashboardData();
+ }, milliseconds);
+ }
+ }
+
+ restoreAutoRefreshSetting() {
+ // Get saved auto-refresh interval from localStorage
+ const savedInterval = localStorage.getItem('stats-auto-refresh-interval');
+
+ if (savedInterval !== null) {
+ const intervalSeconds = parseInt(savedInterval);
+
+ // Find the corresponding dropdown item
+ const dropdownItem = document.querySelector(`[data-interval="${intervalSeconds}"]`);
+ if (dropdownItem) {
+ // Update the button text
+ const autoRefreshBtn = document.querySelector('.auto-refresh-btn');
+ const intervalText = dropdownItem.textContent.trim();
+ autoRefreshBtn.innerHTML = `${intervalText} `;
+
+ // Set the auto-refresh interval
+ this.setAutoRefreshInterval(intervalSeconds);
+ }
+ }
+ }
+
+ showError(message) {
+ console.error(message);
+ // You can implement a toast notification here
+ alert(message); // Simple fallback
+ }
+
+ destroy() {
+ if (this.autoRefreshInterval) {
+ clearInterval(this.autoRefreshInterval);
+ }
+
+ this.charts.forEach(chart => {
+ if (chart.destroy) {
+ chart.destroy();
+ } else if (chart.dispose) {
+ chart.dispose(); // For AnyChart maps
+ }
+ });
+ this.charts.clear();
+ }
+}
+
+// Global functions for toggle view functionality (matching original stats-view)
+function toggleView(chartType) {
+ const chartElement = chartType === 'country' ?
+ document.getElementById(`${chartType}Chart`) :
+ document.getElementById(`${chartType}Chart`);
+ const jsonPre = document.querySelector(`.${chartType}JsonPre`);
+ const jsonDataElement = document.getElementById(`${chartType}Json`);
+
+ if (!chartElement || !jsonPre || !jsonDataElement) return;
+
+ if (chartElement.style.display === 'none') {
+ chartElement.style.display = 'block';
+ jsonPre.style.display = 'none';
+ jsonDataElement.style.display = 'none';
+
+ if (chartType === 'country') {
+ document.getElementById("country-container").style.padding = "5px";
+ }
+ } else {
+ chartElement.style.display = 'none';
+ jsonPre.style.display = 'block';
+ jsonDataElement.style.display = 'block';
+
+ // Show the current API data
+ if (window.dashboard && window.dashboard.apiData) {
+ jsonDataElement.textContent = JSON.stringify(window.dashboard.apiData, null, 2);
+ }
+
+ if (chartType === 'country') {
+ document.getElementById("country-container").style.padding = "20px";
+ }
+ }
+}
+
+// Update chart functions for when selectors change
+function updateTimeSeriesChart() {
+ if (window.dashboard && window.dashboard.apiData) {
+ window.dashboard.updateTimeSeriesChart(window.dashboard.apiData);
+ }
+}
+
+function updateBrowserChart() {
+ if (window.dashboard && window.dashboard.apiData) {
+ window.dashboard.updateBrowserChart(window.dashboard.apiData);
+ }
+}
+
+function updateOsChart() {
+ if (window.dashboard && window.dashboard.apiData) {
+ window.dashboard.updateOsChart(window.dashboard.apiData);
+ }
+}
+
+function updateReferrerChart() {
+ if (window.dashboard && window.dashboard.apiData) {
+ window.dashboard.updateReferrerChart(window.dashboard.apiData);
+ }
+}
+
+function updateCountryChart() {
+ if (window.dashboard && window.dashboard.apiData) {
+ window.dashboard.updateCountryChart(window.dashboard.apiData);
+ }
+}
+
+function updateCityChart() {
+ if (window.dashboard && window.dashboard.apiData) {
+ window.dashboard.updateCityChart(window.dashboard.apiData);
+ }
+}
+
+function updateKeyChart() {
+ if (window.dashboard && window.dashboard.apiData) {
+ window.dashboard.updateKeyChart(window.dashboard.apiData);
+ }
+}
+
+// Initialize dashboard when page loads
+document.addEventListener('DOMContentLoaded', () => {
+ window.dashboard = new StatisticsDashboard();
+
+ // Setup export functionality
+ setupExportFunctionality(window.dashboard);
+});
+
+// Expose closeAllModals to window for use by other scripts like dateRangePicker.js
+window.closeAllModals = closeAllModals;
+
+// Cleanup on page unload
+window.addEventListener('beforeunload', () => {
+ if (window.dashboard) {
+ window.dashboard.destroy();
+ }
+});
+
+// Filter Manager Class
+class FilterManager {
+ constructor() {
+ this.activeFilters = {
+ browser: [],
+ os: [],
+ // device: [], // DISABLED: Reliable device detection not available yet
+ country: [],
+ city: [],
+ referrer: [],
+ key: []
+ };
+ this.onFiltersChanged = null;
+ }
+
+ addFilter(type, value) {
+ if (!this.activeFilters[type]) {
+ this.activeFilters[type] = [];
+ }
+
+ if (!this.activeFilters[type].includes(value)) {
+ this.activeFilters[type].push(value);
+ // Don't notify change immediately - wait for dropdown close
+ }
+ }
+
+ toggleFilter(type, value) {
+ if (!this.activeFilters[type]) {
+ this.activeFilters[type] = [];
+ }
+
+ const index = this.activeFilters[type].indexOf(value);
+ if (index > -1) {
+ // Remove filter
+ this.activeFilters[type].splice(index, 1);
+ } else {
+ // Add filter
+ this.activeFilters[type].push(value);
+ }
+
+ // Immediately notify change for chart interactions
+ this.notifyChange();
+ }
+
+ removeFilter(type, value) {
+ if (!this.activeFilters[type]) return;
+
+ const index = this.activeFilters[type].indexOf(value);
+ if (index > -1) {
+ this.activeFilters[type].splice(index, 1);
+ // Don't notify change immediately - wait for dropdown close
+ }
+ }
+
+ clearFilter(type) {
+ if (this.activeFilters[type] && this.activeFilters[type].length > 0) {
+ this.activeFilters[type] = [];
+ // Don't notify change immediately - wait for dropdown close
+ }
+ }
+
+ clearAllFilters() {
+ // Clear all active filters
+ Object.keys(this.activeFilters).forEach(type => {
+ this.activeFilters[type] = [];
+ });
+
+ // Immediately trigger data refresh for clear all
+ this.notifyChange();
+ }
+
+ isSelected(type, value) {
+ return this.activeFilters[type] && this.activeFilters[type].includes(value);
+ }
+
+ getActiveFilters() {
+ return { ...this.activeFilters };
+ }
+
+ getTotalActiveFilters() {
+ return Object.values(this.activeFilters).reduce((total, filters) => total + filters.length, 0);
+ }
+
+ notifyChange() {
+ if (this.onFiltersChanged) {
+ this.onFiltersChanged();
+ } else {
+ console.warn('FilterManager: onFiltersChanged callback not set');
+ }
+ }
+
+ // Save filters to URL for sharing/bookmarking
+ saveToURL() {
+ const url = new URL(window.location);
+ const params = url.searchParams;
+
+ // Clear existing filter params
+ Object.keys(this.activeFilters).forEach(type => {
+ params.delete(type);
+ });
+
+ // Add active filters
+ Object.keys(this.activeFilters).forEach(type => {
+ if (this.activeFilters[type].length > 0) {
+ params.set(type, this.activeFilters[type].join(','));
+ }
+ });
+
+ // Update URL without reloading
+ window.history.replaceState({}, '', url);
+ }
+
+ // Load filters from URL
+ loadFromURL() {
+ const params = new URLSearchParams(window.location.search);
+ let hasFilters = false;
+
+ Object.keys(this.activeFilters).forEach(type => {
+ const value = params.get(type);
+ if (value) {
+ this.activeFilters[type] = value.split(',').filter(v => v.trim());
+ hasFilters = true;
+ }
+ });
+
+ return hasFilters;
+ }
+}
+
+// ============================================================================
+// Export Functionality
+// ============================================================================
+
+function setupExportFunctionality(dashboard) {
+ const exportBtn = document.querySelector('.export-btn');
+ const exportDropdownMenu = document.getElementById('exportDropdownMenu');
+
+ // Toggle export dropdown
+ exportBtn?.addEventListener('click', function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const isOpen = exportDropdownMenu.classList.contains('active');
+
+ // Close all other modals first
+ closeAllModals(dashboard);
+
+ if (!isOpen) {
+ // Only open if it was previously closed
+ exportDropdownMenu.classList.add('active');
+ exportBtn.classList.add('active');
+ }
+ });
+
+ // Close dropdown when clicking outside
+ document.addEventListener('click', function(e) {
+ if (!e.target.closest('.export-btn-wrapper')) {
+ exportDropdownMenu?.classList.remove('active');
+ exportBtn?.classList.remove('active');
+ }
+ });
+
+ // Handle export button clicks
+ function handleExport(format) {
+ // Build export URL with current filters and parameters (same as loadDashboardData)
+ const params = new URLSearchParams({
+ scope: 'all',
+ format: format
+ });
+
+ // Add date range
+ let startDate, endDate;
+ if (dashboard.startDate && dashboard.endDate) {
+ startDate = new Date(dashboard.startDate);
+ endDate = new Date(dashboard.endDate);
+ } else {
+ const currentRange = dashboard.dateRangePicker.getCurrentRange();
+ startDate = new Date(currentRange.start);
+ endDate = new Date(currentRange.end);
+ }
+ params.append('start_date', startDate.toISOString());
+ params.append('end_date', endDate.toISOString());
+
+ // Add timezone
+ const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ params.append('timezone', userTimezone);
+
+ // Add grouping and metrics
+ params.append('group_by', 'time,browser,os,country,city,referrer,short_code');
+ params.append('metrics', 'clicks,unique_clicks');
+
+ // Add active filters
+ const activeFilters = dashboard.filterManager.getActiveFilters();
+ Object.keys(activeFilters).forEach(filterType => {
+ const values = activeFilters[filterType];
+ if (values && values.length > 0) {
+ params.append(filterType, values.join(','));
+ }
+ });
+
+ const exportUrl = `/api/v1/export?${params.toString()}`;
+
+ // Initiate download
+ fetch(exportUrl, {
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Export failed');
+ }
+ return response.blob();
+ })
+ .then(blob => {
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement('a');
+
+ const fileExtension = format === 'csv' ? 'zip' : format;
+ const timestamp = new Date().toISOString().split('T')[0];
+ link.href = url;
+ link.download = `spoo-me-stats-${timestamp}.${fileExtension}`;
+ document.body.appendChild(link);
+
+ link.click();
+
+ document.body.removeChild(link);
+ window.URL.revokeObjectURL(url);
+
+ // Close menus
+ exportDropdownMenu?.classList.remove('active');
+ exportBtn?.classList.remove('active');
+ })
+ .catch(error => {
+ console.error('Export error:', error);
+ alert('Failed to export data. Please try again.');
+ });
+ }
+
+ // Add click handlers for all export menu items
+ document.querySelectorAll('.export-menu-item').forEach(item => {
+ item.addEventListener('click', function(e) {
+ e.preventDefault();
+ const format = this.getAttribute('data-format');
+ if (format) {
+ handleExport(format);
+ }
+ });
+ });
+}
diff --git a/static/js/header.js b/static/js/header.js
index 835dbecf..864bded7 100644
--- a/static/js/header.js
+++ b/static/js/header.js
@@ -8,4 +8,82 @@ window.addEventListener('scroll', function() {
navbar.classList.remove('scrolled');
mobileNavbar.classList.remove('scrolled');
}
+});
+
+// Fetch GitHub stars
+async function fetchGitHubStars() {
+ try {
+ const response = await fetch('https://api.github.com/repos/spoo-me/url-shortener');
+ if (response.ok) {
+ const data = await response.json();
+ const stars = data.stargazers_count;
+ const formatted = stars >= 1000 ? (stars / 1000).toFixed(1) + 'k' : stars;
+ document.getElementById('github-star-count').textContent = formatted;
+ }
+ } catch (error) {
+ console.log('GitHub API unavailable, using fallback');
+ }
+}
+
+// Mobile menu toggle
+function setupMobileMenu() {
+ const burger = document.querySelector('.burger');
+ const mobileNavbar = document.querySelector('.mobile-navbar');
+ const menu = document.querySelector('.mobile-menu');
+
+ if (burger && menu && mobileNavbar) {
+ burger.addEventListener('click', function(e) {
+ e.stopPropagation();
+ const isExpanded = burger.getAttribute('aria-expanded') === 'true';
+ burger.setAttribute('aria-expanded', String(!isExpanded));
+
+ // Toggle class on navbar to control menu visibility
+ if (!isExpanded) {
+ mobileNavbar.classList.add('menu-open');
+ } else {
+ mobileNavbar.classList.remove('menu-open');
+ }
+ });
+
+ // Close menu when clicking outside
+ document.addEventListener('click', function(e) {
+ if (!burger.contains(e.target) && !menu.contains(e.target)) {
+ burger.setAttribute('aria-expanded', 'false');
+ mobileNavbar.classList.remove('menu-open');
+ }
+ });
+
+ // Close menu when clicking a link
+ menu.querySelectorAll('a').forEach(link => {
+ link.addEventListener('click', function() {
+ burger.setAttribute('aria-expanded', 'false');
+ mobileNavbar.classList.remove('menu-open');
+ });
+ });
+ }
+}
+
+// Profile dropdown toggles
+document.addEventListener('DOMContentLoaded', function(){
+ fetchGitHubStars();
+ setupMobileMenu();
+
+ function setupProfileMenu(buttonId, dropdownId){
+ var btn = document.getElementById(buttonId);
+ var dd = document.getElementById(dropdownId);
+ if(!btn || !dd) return;
+ function toggle(){
+ var open = dd.style.display === 'block';
+ dd.style.display = open ? 'none' : 'block';
+ btn.setAttribute('aria-expanded', String(!open));
+ }
+ btn.addEventListener('click', function(e){ e.stopPropagation(); toggle(); });
+ document.addEventListener('click', function(e){
+ if(dd.contains(e.target) || btn.contains(e.target)) return;
+ dd.style.display = 'none';
+ btn.setAttribute('aria-expanded','false');
+ });
+ }
+ setupProfileMenu('profileButton','profileDropdown');
+ setupProfileMenu('mProfileButton','mProfileDropdown');
});
\ No newline at end of file
diff --git a/static/js/index-qrcode.js b/static/js/index-qrcode.js
index 0c040d42..c39833ff 100644
--- a/static/js/index-qrcode.js
+++ b/static/js/index-qrcode.js
@@ -1,63 +1,98 @@
-// Get all elements with the "stats-button" class
-const statsButtons = document.querySelectorAll('.stats-button');
+// Reusable handler for copy button clicks
+function handleCopyClick(button) {
+ const url = button.getAttribute('data-url');
-// Add click event listener to each stats button
-statsButtons.forEach(button => {
- button.addEventListener('click', () => {
- // Get the URL from the data-url attribute of the button
- const url = "/stats/" + button.parentNode.parentNode.parentNode.querySelector('.short-url a').getAttribute('href');
+ // Create a temporary input element to copy the URL
+ const tempInput = document.createElement('input');
+ tempInput.value = url;
+ document.body.appendChild(tempInput);
- // Redirect the user to the stats URL
- window.location.href = url;
- });
-});
+ // Select the URL in the input element
+ tempInput.select();
+ tempInput.setSelectionRange(0, 99999); // For mobile devices
+
+ // Copy the URL to the clipboard
+ document.execCommand('copy');
+ // Remove the temporary input element
+ document.body.removeChild(tempInput);
-const copyButtons = document.querySelectorAll('.copy-button');
-// Add click event listener to each copy button
-copyButtons.forEach(button => {
- button.addEventListener('click', () => {
- // Get the URL from the data-url attribute of the button
- const url = button.getAttribute('data-url');
+ // Provide visual feedback to indicate successful copying
+ button.innerText = 'Copied!';
+ setTimeout(() => {
+ button.innerText = 'Copy';
+ }, 1000);
+}
- // Create a temporary input element to copy the URL
- const tempInput = document.createElement('input');
- tempInput.value = url;
- document.body.appendChild(tempInput);
+// Reusable handler for stats button clicks
+function handleStatsClick(button) {
+ const href = button.parentNode.parentNode.parentNode.querySelector('.short-url a').getAttribute('href');
+ const alias = href.replace(/^\//, '');
+ window.location.href = `/stats/${alias}`;
+}
+
+// Single document-level event delegation for copy and stats buttons
+document.addEventListener('click', (e) => {
+ // Handle copy button clicks
+ if (e.target.classList.contains('copy-button')) {
+ handleCopyClick(e.target);
+ }
+ // Handle stats button clicks
+ else if (e.target.classList.contains('stats-button')) {
+ handleStatsClick(e.target);
+ }
+});
- // Select the URL in the input element
- tempInput.select();
- tempInput.setSelectionRange(0, 99999); // For mobile devices
+// Get all elements with the "qr-code" class
+function renderRecentURLs() {
+ const container = document.getElementById('recentURLs');
+ if (!container) return;
- // Copy the URL to the clipboard
- document.execCommand('copy');
+ let list = [];
+ try { list = JSON.parse(localStorage.getItem('recentURLs')) || []; } catch (_) { list = []; }
+ container.innerHTML = '';
- // Remove the temporary input element
- document.body.removeChild(tempInput);
+ list.forEach((alias) => {
+ const shortUrl = `${window.location.origin}/${alias}`;
- // Provide visual feedback to indicate successful copying
- button.innerText = 'Copied!';
- setTimeout(() => {
- button.innerText = 'Copy';
- }, 1000);
+ const wrapper = document.createElement('div');
+ wrapper.className = 'url-container';
+ wrapper.innerHTML = `
+
+
+ `;
+ container.appendChild(wrapper);
});
-});
-// Get all elements with the "qr-code" class
-const qrCodeElements = document.querySelectorAll('.qr-code');
-
-// Iterate over each QR code element
-qrCodeElements.forEach(element => {
- // Get the URL from the data-url attribute
- const url = element.getAttribute('data-url');
-
- // Generate the QR code using QRCode.js
- const qrcode = new QRCode(element, {
- text: url,
- width: 40,
- height: 40,
- correctLevel: QRCode.CorrectLevel.L,
- margin: 0,
- colorDark: '#000000',
- colorLight: '#ffffff',
+
+ // Generate QR codes for newly rendered items
+ // Note: Event listeners for copy/stats buttons are handled by document-level delegation
+ const qrCodeElements = container.querySelectorAll('.qr-code');
+ qrCodeElements.forEach(element => {
+ const url = element.getAttribute('data-url');
+ const qrcode = new QRCode(element, {
+ text: url,
+ width: 40,
+ height: 40,
+ correctLevel: QRCode.CorrectLevel.L,
+ margin: 0,
+ colorDark: '#000000',
+ colorLight: '#ffffff',
+ });
});
-});
\ No newline at end of file
+}
+
+document.addEventListener('DOMContentLoaded', renderRecentURLs);
\ No newline at end of file
diff --git a/static/js/index-script.js b/static/js/index-script.js
index d0fecf55..da257207 100644
--- a/static/js/index-script.js
+++ b/static/js/index-script.js
@@ -5,24 +5,15 @@ function toggleDropdown() {
const inputBox = document.querySelector('#alias');
-// Track if the alias notification has been shown
-let aliasNotificationShown = false;
+if (inputBox) {
+ inputBox.addEventListener('focus', (e) => {
+ document.querySelector('.buttonIn')?.classList.add('focus');
+ });
-inputBox.addEventListener('focus', (e) => {
- document.querySelector('.buttonIn').classList.add('focus');
-});
-
-inputBox.addEventListener('blur', (e) => {
- document.querySelector('.buttonIn').classList.remove('focus');
-});
-
-inputBox.addEventListener('click', (e) => {
- // Show the notification about 16-character limit only once per session
- if (!aliasNotificationShown) {
- customTopNotification("AliasUpdate", "✨ New: Custom aliases can now be up to 16 characters long!", 10, "success");
- aliasNotificationShown = true;
- }
-});
+ inputBox.addEventListener('blur', (e) => {
+ document.querySelector('.buttonIn')?.classList.remove('focus');
+ });
+}
function get_metrics() {
fetch('/metric')
@@ -45,4 +36,69 @@ function get_metrics() {
});
}
-document.onload = get_metrics();
\ No newline at end of file
+document.onload = get_metrics();
+
+// Handle form submission via API v1
+document.addEventListener('DOMContentLoaded', () => {
+ const form = document.querySelector('.form-section form');
+ if (!form) return;
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ if (typeof validateURL === 'function' && !validateURL()) {
+ return;
+ }
+ if (typeof validatePassword === 'function' && !validatePassword()) {
+ return;
+ }
+
+ const url = document.getElementById('long-url').value.trim();
+ const alias = document.getElementById('alias').value.trim();
+ const password = document.getElementById('password').value;
+ const maxClicksInput = document.getElementById('max-clicks').value;
+ const blockBots = document.getElementById('block-bots').checked;
+
+ const payload = {
+ long_url: url,
+ alias: alias || undefined,
+ password: password || undefined,
+ max_clicks: maxClicksInput ? parseInt(maxClicksInput, 10) : undefined,
+ block_bots: blockBots ? true : undefined,
+ };
+
+ const submitBtn = form.querySelector('button[type="submit"]');
+ const prevText = submitBtn ? submitBtn.textContent : '';
+ if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Shortening...'; }
+
+ try {
+ const res = await authFetch('/api/v1/shorten', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ const err = data && data.error ? data.error : 'Failed to shorten URL';
+ customTopNotification('ShortenError', err, 8, 'error');
+ return;
+ }
+
+ // Maintain recent URLs in localStorage (latest 3)
+ const key = 'recentURLs';
+ let list = [];
+ try { list = JSON.parse(localStorage.getItem(key)) || []; } catch (_) { list = []; }
+ list.unshift(data.alias);
+ list = Array.from(new Set(list)).slice(0, 3);
+ localStorage.setItem(key, JSON.stringify(list));
+
+ // Navigate to result page
+ window.location.href = `/result/${encodeURIComponent(data.alias)}`;
+ } catch (err) {
+ customTopNotification('NetworkError', 'Network error. Please try again.', 8, 'error');
+ } finally {
+ if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = prevText; }
+ }
+ });
+});
\ No newline at end of file
diff --git a/static/js/url-manager.js b/static/js/url-manager.js
new file mode 100644
index 00000000..7d0529df
--- /dev/null
+++ b/static/js/url-manager.js
@@ -0,0 +1,741 @@
+/**
+ * URL Management Modal System
+ * Handles editing, deactivating, and deleting URLs
+ */
+
+class UrlManager {
+ constructor() {
+ this.currentUrlData = null;
+ this.hostUrl = window.dashboardConfig?.hostUrl || '';
+
+ // Modal elements
+ this.modal = document.getElementById('url-management-modal');
+ this.deleteModal = document.getElementById('delete-confirmation-modal');
+ this.form = document.getElementById('url-edit-form');
+
+ // Form elements
+ this.aliasInput = document.getElementById('edit-alias');
+ this.longUrlInput = document.getElementById('edit-long-url');
+ this.passwordInput = document.getElementById('edit-password');
+ this.removePasswordCheckbox = document.getElementById('remove-password-checkbox');
+ this.passwordStatus = document.getElementById('password-status');
+ this.privateStatsCheckbox = document.getElementById('edit-private-stats');
+ this.maxClicksInput = document.getElementById('edit-max-clicks');
+ this.expireAfterInput = document.getElementById('edit-expire-after');
+ this.blockBotsCheckbox = document.getElementById('edit-block-bots');
+
+ // Buttons
+ this.deactivateBtn = document.getElementById('btn-deactivate');
+ this.deleteBtn = document.getElementById('btn-delete-url');
+ this.saveBtn = document.getElementById('btn-save');
+
+ // Delete confirmation elements
+ this.deleteUrlPreview = document.getElementById('delete-url-preview');
+ this.deleteConfirmationInput = document.getElementById('delete-confirmation-input');
+ this.cancelDeleteBtn = document.getElementById('btn-cancel-delete');
+ this.confirmDeleteBtn = document.getElementById('btn-confirm-delete');
+
+ this.init();
+ }
+
+ init() {
+ // Tab switching
+ this.initTabs();
+
+ // Modal event listeners
+ this.modal?.querySelector('.modal-close')?.addEventListener('click', () => this.closeModal());
+ this.modal?.querySelector('.modal-backdrop')?.addEventListener('click', () => this.closeModal());
+
+ // Action buttons
+ this.deactivateBtn?.addEventListener('click', () => this.deactivateUrl());
+ this.deleteBtn?.addEventListener('click', () => this.showDeleteConfirmation());
+ this.saveBtn?.addEventListener('click', () => this.saveChanges());
+
+ // Password checkbox logic
+ this.removePasswordCheckbox?.addEventListener('change', () => this.handlePasswordCheckboxChange());
+
+ // Delete confirmation modal
+ this.deleteModal?.querySelector('.modal-close')?.addEventListener('click', () => this.closeDeleteModal());
+ this.deleteModal?.querySelector('.modal-backdrop')?.addEventListener('click', () => this.closeDeleteModal());
+ this.cancelDeleteBtn?.addEventListener('click', () => this.closeDeleteModal());
+ this.confirmDeleteBtn?.addEventListener('click', () => this.confirmDelete());
+
+ // Delete confirmation input validation
+ this.deleteConfirmationInput?.addEventListener('input', () => this.validateDeleteInput());
+
+ // Form submission
+ this.form?.addEventListener('submit', (e) => {
+ e.preventDefault();
+ this.saveChanges();
+ });
+
+ // ESC key to close modals
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ if (this.deleteModal?.classList.contains('active')) {
+ this.closeDeleteModal();
+ } else if (this.modal?.classList.contains('active')) {
+ this.closeModal();
+ }
+ }
+ });
+
+ // Attach to row action buttons
+ this.attachRowListeners();
+ }
+
+ initTabs() {
+ if (!this.modal) return;
+
+ const tabs = this.modal.querySelectorAll('.tab');
+ const tabContents = this.modal.querySelectorAll('.tab-content');
+ const tabsContainer = this.modal.querySelector('.tabs');
+
+ tabs.forEach((tab, index) => {
+ tab.addEventListener('click', () => {
+ const targetTab = tab.getAttribute('data-tab');
+
+ // Update tab indicator position
+ if (tabsContainer) {
+ tabsContainer.setAttribute('data-active', index.toString());
+ }
+
+ // Update tab states
+ tabs.forEach(t => t.classList.remove('active'));
+ tab.classList.add('active');
+
+ // Update content states - simple show/hide
+ tabContents.forEach(content => {
+ content.classList.remove('active');
+ if (content.getAttribute('data-tab') === targetTab) {
+ content.classList.add('active');
+ }
+ });
+ });
+ });
+ }
+
+ attachRowListeners() {
+ // Use event delegation for dynamically added rows
+ document.addEventListener('click', (e) => {
+ const clickableRow = e.target.closest('.clickable-row');
+
+ if (clickableRow) {
+ // Prevent opening modal if clicking on links
+ if (e.target.closest('a')) {
+ return;
+ }
+
+ const urlDataStr = clickableRow.getAttribute('data-url-data');
+ if (urlDataStr) {
+ try {
+ const urlData = JSON.parse(urlDataStr);
+ this.editUrl(urlData);
+ } catch (error) {
+ console.error('Error parsing URL data:', error);
+ }
+ }
+ }
+ });
+ }
+
+
+
+ editUrl(urlData) {
+ this.currentUrlData = urlData;
+ this.populateForm(urlData);
+ this.showModal();
+ }
+
+ populateForm(urlData) {
+ // Basic tab
+ if (this.aliasInput) this.aliasInput.value = urlData.alias || '';
+ if (this.longUrlInput) this.longUrlInput.value = urlData.long_url || '';
+
+ // Security tab - Password handling
+ if (this.passwordInput) this.passwordInput.value = ''; // Never populate actual password
+
+ // Setup password checkbox and status
+ if (this.removePasswordCheckbox) {
+ this.removePasswordCheckbox.checked = false;
+
+ if (urlData.password_set) {
+ // URL has password - enable checkbox, show status
+ this.removePasswordCheckbox.disabled = false;
+ if (this.passwordStatus) this.passwordStatus.textContent = 'Password is currently set';
+ } else {
+ // URL has no password - disable checkbox, show status
+ this.removePasswordCheckbox.disabled = true;
+ if (this.passwordStatus) this.passwordStatus.textContent = 'No password set';
+ }
+ }
+
+ if (this.privateStatsCheckbox) this.privateStatsCheckbox.checked = urlData.private_stats || false;
+
+ // Advanced tab
+ if (this.maxClicksInput) this.maxClicksInput.value = urlData.max_clicks || '';
+ if (this.expireAfterInput) {
+ if (urlData.expire_after) {
+ const date = new Date(urlData.expire_after * 1000);
+ this.expireAfterInput.value = date.toISOString().slice(0, 16);
+ } else {
+ this.expireAfterInput.value = '';
+ }
+ }
+ if (this.blockBotsCheckbox) this.blockBotsCheckbox.checked = urlData.block_bots || false;
+
+ // Update deactivate button based on current status
+ this.updateDeactivateButton(urlData.status);
+ }
+
+ handlePasswordCheckboxChange() {
+ if (!this.removePasswordCheckbox || !this.passwordInput) return;
+
+ if (this.removePasswordCheckbox.checked) {
+ // User wants to remove password - disable input
+ this.passwordInput.disabled = true;
+ this.passwordInput.value = '';
+ this.passwordInput.placeholder = 'Password will be removed';
+ if (this.passwordStatus) this.passwordStatus.textContent = 'Password will be removed';
+ } else {
+ // User unchecked - enable input
+ this.passwordInput.disabled = false;
+ this.passwordInput.placeholder = 'Enter new password';
+ if (this.passwordStatus) {
+ const hasPassword = this.currentUrlData?.password_set;
+ this.passwordStatus.textContent = hasPassword ? 'Password is currently set' : 'No password set';
+ }
+ }
+ }
+
+ updateDeactivateButton(status) {
+ if (this.deactivateBtn) {
+ if (status === 'ACTIVE') {
+ this.deactivateBtn.innerHTML = 'Deactivate ';
+ this.deactivateBtn.className = 'btn btn-warning';
+ } else {
+ this.deactivateBtn.innerHTML = 'Activate ';
+ this.deactivateBtn.className = 'btn btn-success';
+ }
+ }
+ }
+
+ showModal() {
+ if (this.modal) {
+ this.modal.classList.add('active');
+ document.body.style.overflow = 'hidden';
+
+ // Initialize tab indicator to first tab
+ const tabsContainer = this.modal.querySelector('.tabs');
+ if (tabsContainer) {
+ tabsContainer.setAttribute('data-active', '0');
+ }
+
+ // Focus first input
+ setTimeout(() => {
+ this.aliasInput?.focus();
+ }, 400);
+ }
+ }
+
+ closeModal() {
+ if (this.modal) {
+ this.modal.classList.remove('active');
+ document.body.style.overflow = '';
+ this.currentUrlData = null;
+ }
+ }
+
+ async saveChanges() {
+ if (!this.currentUrlData) return;
+
+ const updateData = {};
+ let hasChanges = false;
+
+ // Check alias changes
+ const currentAlias = this.aliasInput?.value?.trim();
+ if (currentAlias && currentAlias !== this.currentUrlData.alias) {
+ updateData.alias = currentAlias;
+ hasChanges = true;
+ }
+
+ // Check long_url changes
+ const currentLongUrl = this.longUrlInput?.value?.trim();
+ if (currentLongUrl && currentLongUrl !== this.currentUrlData.long_url) {
+ updateData.long_url = currentLongUrl;
+ hasChanges = true;
+ }
+
+ // Check password changes
+ if (this.removePasswordCheckbox?.checked) {
+ // User explicitly wants to remove password
+ updateData.password = null;
+ hasChanges = true;
+ } else {
+ // Check if new password was entered
+ const currentPassword = this.passwordInput?.value?.trim();
+ if (currentPassword && currentPassword !== '') {
+ // New password entered
+ updateData.password = currentPassword;
+ hasChanges = true;
+ }
+ // If password field is empty and checkbox not checked = no change (keep existing)
+ }
+
+ // Check max_clicks changes (including removal)
+ const currentMaxClicks = this.maxClicksInput?.value?.trim();
+ const originalMaxClicks = this.currentUrlData.max_clicks;
+ if (currentMaxClicks === '' && originalMaxClicks) {
+ // Remove max_clicks
+ updateData.max_clicks = null;
+ hasChanges = true;
+ } else if (currentMaxClicks && parseInt(currentMaxClicks) !== originalMaxClicks) {
+ // Update max_clicks
+ updateData.max_clicks = parseInt(currentMaxClicks);
+ hasChanges = true;
+ }
+
+ // Check expire_after changes (including removal)
+ const currentExpireAfter = this.expireAfterInput?.value;
+ const originalExpireAfter = this.currentUrlData.expire_after;
+ if (currentExpireAfter === '' && originalExpireAfter) {
+ // Remove expiration
+ updateData.expire_after = null;
+ hasChanges = true;
+ } else if (currentExpireAfter) {
+ const newExpireTs = Math.floor(new Date(currentExpireAfter).getTime() / 1000);
+ if (newExpireTs !== originalExpireAfter) {
+ updateData.expire_after = newExpireTs;
+ hasChanges = true;
+ }
+ }
+
+ // Check private_stats changes
+ const currentPrivateStats = this.privateStatsCheckbox?.checked || false;
+ if (currentPrivateStats !== this.currentUrlData.private_stats) {
+ updateData.private_stats = currentPrivateStats;
+ hasChanges = true;
+ }
+
+ // Check block_bots changes
+ const currentBlockBots = this.blockBotsCheckbox?.checked || false;
+ if (currentBlockBots !== this.currentUrlData.block_bots) {
+ updateData.block_bots = currentBlockBots;
+ hasChanges = true;
+ }
+
+ // If no changes detected, show message and return
+ if (!hasChanges) {
+ this.showNotification('No changes detected', 'info');
+ return;
+ }
+
+ try {
+ this.saveBtn.disabled = true;
+ this.saveBtn.innerHTML = 'Saving... ';
+
+ // Debug: log what we're sending
+ console.log('Sending update data:', updateData);
+
+ const response = await this.apiCall(`/api/v1/urls/${this.getUrlId()}`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(updateData)
+ });
+
+ if (response.ok) {
+ const responseData = await response.json();
+
+ // Update current data with the response
+ this.currentUrlData = { ...this.currentUrlData, ...responseData };
+
+ // Update password UI based on changes
+ if (this.removePasswordCheckbox?.checked) {
+ // Password was removed
+ this.currentUrlData.password_set = false;
+ if (this.passwordStatus) this.passwordStatus.textContent = 'No password set';
+ if (this.removePasswordCheckbox) {
+ this.removePasswordCheckbox.checked = false;
+ this.removePasswordCheckbox.disabled = true;
+ }
+ if (this.passwordInput) {
+ this.passwordInput.disabled = false;
+ this.passwordInput.placeholder = 'Enter new password';
+ }
+ } else if (updateData.password) {
+ // Password was set/changed
+ this.currentUrlData.password_set = true;
+ if (this.passwordStatus) this.passwordStatus.textContent = 'Password is currently set';
+ if (this.removePasswordCheckbox) this.removePasswordCheckbox.disabled = false;
+ if (this.passwordInput) this.passwordInput.value = ''; // Clear the field after saving
+ }
+
+ this.showNotification('URL updated successfully', 'success');
+
+ // Update the table row with new data immediately
+ this.updateTableRow(this.currentUrlData);
+
+ // Also refresh the full list in the background
+ this.refreshUrlList();
+
+ // Close the modal after successful save
+ setTimeout(() => {
+ this.closeModal();
+ }, 200); // Small delay to allow user to see the success notification
+ } else {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to update URL');
+ }
+ } catch (error) {
+ console.error('Error updating URL:', error);
+ this.showNotification(error.message, 'error');
+ } finally {
+ this.saveBtn.disabled = false;
+ this.saveBtn.innerHTML = 'Save Changes ';
+ }
+ }
+
+ async deactivateUrl() {
+ if (!this.currentUrlData) return;
+
+ const currentStatus = this.currentUrlData.status || 'ACTIVE';
+ const newStatus = currentStatus === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
+ const originalButtonContent = this.deactivateBtn.innerHTML;
+
+ try {
+ this.deactivateBtn.disabled = true;
+ this.deactivateBtn.innerHTML = 'Processing... ';
+
+ const response = await this.apiCall(`/api/v1/urls/${this.getUrlId()}/status`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ status: newStatus })
+ });
+
+ if (response.ok) {
+ // Update the current data
+ this.currentUrlData.status = newStatus;
+
+ // Update the button appearance
+ this.updateDeactivateButton(newStatus);
+
+ // Show success notification
+ const action = newStatus === 'ACTIVE' ? 'activated' : 'deactivated';
+ this.showNotification(`URL ${action} successfully`, 'success');
+
+ // Update the table row immediately
+ this.updateTableRow(this.currentUrlData);
+
+ // Also refresh the full list in the background
+ this.refreshUrlList();
+ } else {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to update URL status');
+ }
+ } catch (error) {
+ console.error('Error updating URL status:', error);
+ this.showNotification(error.message, 'error');
+ // Restore original button content on error
+ this.deactivateBtn.innerHTML = originalButtonContent;
+ } finally {
+ this.deactivateBtn.disabled = false;
+ }
+ }
+
+ showDeleteConfirmation() {
+ if (!this.currentUrlData) return;
+
+ const fullUrl = `${this.hostUrl}${this.currentUrlData.alias}`;
+ if (this.deleteUrlPreview) {
+ this.deleteUrlPreview.textContent = fullUrl;
+ }
+
+ if (this.deleteConfirmationInput) {
+ this.deleteConfirmationInput.value = '';
+ }
+
+ if (this.confirmDeleteBtn) {
+ this.confirmDeleteBtn.disabled = true;
+ }
+
+ if (this.deleteModal) {
+ this.deleteModal.classList.add('active');
+
+ setTimeout(() => {
+ this.deleteConfirmationInput?.focus();
+ }, 100);
+ }
+ }
+
+ closeDeleteModal() {
+ if (this.deleteModal) {
+ this.deleteModal.classList.remove('active');
+ }
+ }
+
+ validateDeleteInput() {
+ if (!this.deleteConfirmationInput || !this.confirmDeleteBtn || !this.currentUrlData) return;
+
+ const inputValue = this.deleteConfirmationInput.value.trim();
+ const expectedAlias = this.currentUrlData.alias;
+
+ this.confirmDeleteBtn.disabled = inputValue !== expectedAlias;
+ }
+
+ async confirmDelete() {
+ if (!this.currentUrlData) return;
+
+ // Store the alias before making the API call (in case currentUrlData gets cleared)
+ const aliasToDelete = this.currentUrlData.alias;
+ console.log('About to delete URL with alias:', aliasToDelete);
+
+ try {
+ this.confirmDeleteBtn.disabled = true;
+ this.confirmDeleteBtn.innerHTML = 'Deleting... ';
+
+ const response = await this.apiCall(`/api/v1/urls/${this.getUrlId()}`, {
+ method: 'DELETE'
+ });
+
+ if (response.ok) {
+ this.showNotification('URL deleted successfully', 'success');
+ this.closeDeleteModal();
+ this.closeModal();
+
+ // Remove the row from the table immediately using the stored alias
+ this.removeUrlFromTable(aliasToDelete);
+
+ // Also refresh the full list
+ this.refreshUrlList();
+ } else {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to delete URL');
+ }
+ } catch (error) {
+ console.error('Error deleting URL:', error);
+ this.showNotification(error.message, 'error');
+ } finally {
+ this.confirmDeleteBtn.disabled = false;
+ this.confirmDeleteBtn.innerHTML = 'Delete Permanently ';
+ }
+ }
+
+ viewStats(alias) {
+ // Navigate to stats page or open stats modal
+ window.open(`/stats/${alias}`, '_blank');
+ }
+
+ getUrlId() {
+ // Use the actual ObjectId returned by the API
+ return this.currentUrlData?.id;
+ }
+
+ async apiCall(url, options = {}) {
+ const defaultOptions = {
+ credentials: 'same-origin',
+ headers: {
+ 'Accept': 'application/json',
+ ...options.headers
+ }
+ };
+
+ return fetch(url, { ...defaultOptions, ...options });
+ }
+
+ showNotification(message, type = 'info') {
+ // Create a simple notification system
+ const notification = document.createElement('div');
+ notification.className = `notification notification-${type}`;
+ notification.innerHTML = `
+
+
+ ${message}
+
+ `;
+
+ // Add notification styles if not present
+ if (!document.getElementById('notification-styles')) {
+ const styles = document.createElement('style');
+ styles.id = 'notification-styles';
+ styles.textContent = `
+ .notification {
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ z-index: 10000;
+ background: rgba(255, 255, 255, 0.1);
+ backdrop-filter: blur(20px);
+ border-radius: 8px;
+ padding: 16px 20px;
+ color: white;
+ font-size: 14px;
+ font-weight: 500;
+ transform: translateX(100%);
+ transition: transform 0.3s ease;
+ border-left: 4px solid;
+ }
+ .notification-success { border-left-color: #10b981; }
+ .notification-error { border-left-color: #ef4444; }
+ .notification-info { border-left-color: #3b82f6; }
+ .notification.show { transform: translateX(0); }
+ .notification-content {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+ `;
+ document.head.appendChild(styles);
+ }
+
+ document.body.appendChild(notification);
+
+ // Animate in
+ setTimeout(() => notification.classList.add('show'), 100);
+
+ // Auto remove
+ setTimeout(() => {
+ notification.classList.remove('show');
+ setTimeout(() => notification.remove(), 300);
+ }, 3000);
+ }
+
+ removeUrlFromTable(alias) {
+ if (!alias) {
+ console.error('No alias provided for removal');
+ return;
+ }
+
+ console.log('Removing URL from table:', alias);
+
+ // Find and remove the table row for the deleted URL
+ const rows = document.querySelectorAll('.clickable-row');
+ let rowFound = false;
+
+ rows.forEach(row => {
+ const urlDataStr = row.getAttribute('data-url-data');
+ if (urlDataStr) {
+ try {
+ const urlData = JSON.parse(urlDataStr);
+ if (urlData.alias === alias) {
+ rowFound = true;
+ console.log('Found row to remove:', urlData);
+
+ // Add fade-out animation
+ row.style.transition = 'all 0.3s ease';
+ row.style.opacity = '0';
+ row.style.transform = 'translateX(-20px)';
+
+ // Remove after animation
+ setTimeout(() => {
+ row.remove();
+ console.log('Row removed from DOM');
+ }, 300);
+ }
+ } catch (error) {
+ console.error('Error parsing URL data for removal:', error);
+ }
+ }
+ });
+
+ if (!rowFound) {
+ console.warn('No row found with alias:', alias);
+ }
+ }
+
+ updateTableRow(updatedUrlData) {
+ // Find and update the table row with new data
+ const rows = document.querySelectorAll('.clickable-row');
+ rows.forEach(row => {
+ const urlDataStr = row.getAttribute('data-url-data');
+ if (urlDataStr) {
+ try {
+ const urlData = JSON.parse(urlDataStr);
+ if (urlData.alias === updatedUrlData.alias || urlData.id === updatedUrlData.id) {
+ // Update the stored data
+ row.setAttribute('data-url-data', JSON.stringify(updatedUrlData));
+
+ // Update visible elements
+ const longUrlElement = row.querySelector('.link-long');
+ if (longUrlElement && updatedUrlData.long_url) {
+ longUrlElement.textContent = updatedUrlData.long_url;
+ longUrlElement.title = updatedUrlData.long_url;
+ }
+
+ const shortUrlElement = row.querySelector('.link-short');
+ if (shortUrlElement && updatedUrlData.alias) {
+ const displayHost = this.hostUrl.replace(/\/+$/, '') + '/';
+ shortUrlElement.textContent = displayHost.replace(/^https?:\/\//i, '') + updatedUrlData.alias;
+ shortUrlElement.href = '/' + updatedUrlData.alias;
+ }
+
+ // Update status badges
+ const activeBadge = row.querySelector('.badge-active');
+ const inactiveBadge = row.querySelector('.badge-inactive');
+ if (activeBadge && inactiveBadge) {
+ if (updatedUrlData.status === 'ACTIVE') {
+ activeBadge.style.display = 'inline-flex';
+ inactiveBadge.style.display = 'none';
+ } else if (updatedUrlData.status === 'INACTIVE') {
+ activeBadge.style.display = 'none';
+ inactiveBadge.style.display = 'inline-flex';
+ } else {
+ activeBadge.style.display = 'none';
+ inactiveBadge.style.display = 'none';
+ }
+ }
+
+ const passwordBadge = row.querySelector('.badge-password');
+ if (passwordBadge) {
+ passwordBadge.style.display = updatedUrlData.password_set ? 'inline-flex' : 'none';
+ }
+
+ const maxClicksBadge = row.querySelector('.badge-max-clicks');
+ if (maxClicksBadge) {
+ if (updatedUrlData.max_clicks) {
+ maxClicksBadge.style.display = 'inline-flex';
+ maxClicksBadge.setAttribute('data-tooltip', `Max clicks: ${updatedUrlData.max_clicks}`);
+ } else {
+ maxClicksBadge.style.display = 'none';
+ }
+ }
+
+ const privateBadge = row.querySelector('.badge-private');
+ if (privateBadge) {
+ privateBadge.style.display = updatedUrlData.private_stats ? 'inline-flex' : 'none';
+ }
+
+ const blockBotsBadge = row.querySelector('.badge-block-bots');
+ if (blockBotsBadge) {
+ blockBotsBadge.style.display = updatedUrlData.block_bots ? 'inline-flex' : 'none';
+ }
+
+ // Add update animation
+ row.style.transition = 'all 0.3s ease';
+ row.style.background = 'rgba(16, 185, 129, 0.1)';
+ setTimeout(() => {
+ row.style.background = '';
+ }, 1000);
+ }
+ } catch (error) {
+ console.error('Error parsing URL data for update:', error);
+ }
+ }
+ });
+ }
+
+ refreshUrlList() {
+ // Trigger a refresh of the URL list
+ if (window.fetchData && typeof window.fetchData === 'function') {
+ window.fetchData();
+ }
+ }
+}
+
+// Initialize URL manager when DOM is loaded
+document.addEventListener('DOMContentLoaded', () => {
+ window.urlManager = new UrlManager();
+});
diff --git a/static/js/v2-announcement.js b/static/js/v2-announcement.js
new file mode 100644
index 00000000..4b0a1d9a
--- /dev/null
+++ b/static/js/v2-announcement.js
@@ -0,0 +1,497 @@
+// V2 Announcement Modal
+class V2Announcement {
+ constructor() {
+ this.currentStep = 1;
+ this.totalSteps = 7; // Welcome + 5 features + API + CTA (removed Performance slide)
+ this.modalShown = false;
+ this.autoShowDelay = 30000; // 30 seconds
+ this.autoAdvanceInterval = 15000; // 15 seconds per slide
+ this.slideTimer = null;
+ this.prefersReducedMotion = (typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) || false;
+ this.confettiPromise = null;
+ this.confettiInstance = null;
+ this.preloadedPreviewImages = new Set();
+ this.init();
+ }
+
+ init() {
+ // Check if user has already seen the announcement
+ if (localStorage.getItem('v2_announcement_seen') === 'true') {
+ return;
+ }
+
+ // Show badge
+ this.showBadge();
+
+ // Auto-show modal after delay
+ setTimeout(() => {
+ if (!this.modalShown) {
+ this.openModal();
+ }
+ }, this.autoShowDelay);
+
+ // Setup event listeners
+ this.setupEventListeners();
+ // Setup preview tooltips
+ this.setupPreviewTooltips();
+ }
+
+ showBadge() {
+ const badge = document.getElementById('v2-badge');
+ if (badge) {
+ badge.style.display = 'flex';
+ }
+ }
+
+ setupEventListeners() {
+ // Badge click
+ const badge = document.getElementById('v2-badge');
+ if (badge) {
+ badge.addEventListener('click', () => this.openModal());
+ }
+
+ // Overlay click
+ const overlay = document.getElementById('v2-modal-overlay');
+ if (overlay) {
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) {
+ this.closeModal();
+ }
+ });
+ }
+
+ // Keyboard navigation
+ document.addEventListener('keydown', (e) => {
+ if (!this.modalShown) return;
+
+ if (e.key === 'Escape') {
+ this.closeModal();
+ } else if (e.key === 'ArrowRight') {
+ this.nextStep();
+ } else if (e.key === 'ArrowLeft') {
+ this.prevStep();
+ }
+ });
+
+ // Navigation buttons
+ const nextBtns = document.querySelectorAll('.v2-next-btn');
+ nextBtns.forEach(btn => {
+ btn.addEventListener('click', () => this.nextStep());
+ });
+
+ const prevBtns = document.querySelectorAll('.v2-prev-btn');
+ prevBtns.forEach(btn => {
+ btn.addEventListener('click', () => this.prevStep());
+ });
+
+ // Progress dots
+ const dots = document.querySelectorAll('.v2-progress-dot');
+ dots.forEach((dot, index) => {
+ dot.addEventListener('click', () => this.showStep(index + 1));
+ });
+
+ // Continue as guest
+ const guestBtn = document.getElementById('v2-guest-btn');
+ if (guestBtn) {
+ guestBtn.addEventListener('click', () => this.closeModal());
+ }
+
+ // Login button
+ const loginBtn = document.getElementById('v2-login-btn');
+ if (loginBtn) {
+ loginBtn.addEventListener('click', () => {
+ this.closeModal();
+ // Trigger your existing login modal
+ setTimeout(() => {
+ const authBtn = document.querySelector('[onclick="openAuthModal(\'login\')"]');
+ if (authBtn) {
+ authBtn.click();
+ } else {
+ // Fallback: try to call the function directly
+ if (typeof openAuthModal === 'function') {
+ openAuthModal('login');
+ }
+ }
+ }, 300);
+ });
+ }
+ }
+
+ openModal() {
+ if (this.modalShown) return;
+
+ this.modalShown = true;
+ const overlay = document.getElementById('v2-modal-overlay');
+ if (overlay) {
+ overlay.classList.add('active');
+ document.body.style.overflow = 'hidden';
+
+ // Show first step
+ this.showStep(1);
+ // Preload preview images used in feature tooltips
+ this.preloadPreviewImages();
+ // start auto-advance timer and progress animation
+ this.startSlideTimer();
+
+ // Trigger confetti after a short delay
+ setTimeout(() => {
+ this.fireConfetti();
+ }, 400);
+ }
+ }
+
+ preloadPreviewImages() {
+ if (this.prefersReducedMotion) return;
+
+ const items = document.querySelectorAll('.v2-feature-highlight-item[data-preview-url], .v2-preview-tooltip[data-preview-url]');
+ items.forEach((el) => {
+ const url = el.getAttribute('data-preview-url');
+ if (!url || this.preloadedPreviewImages.has(url)) return;
+
+ const img = new Image();
+ img.src = url;
+ this.preloadedPreviewImages.add(url);
+ });
+ }
+
+ setupPreviewTooltips() {
+ // don't run on touch devices
+ if ('ontouchstart' in window) return;
+
+ // create or reuse a single global tooltip element appended to body
+ let globalTooltip = document.querySelector('.v2-preview-tooltip-global');
+ if (!globalTooltip) {
+ globalTooltip = document.createElement('div');
+ globalTooltip.className = 'v2-preview-tooltip-global';
+ document.body.appendChild(globalTooltip);
+ }
+
+ const bullets = document.querySelectorAll('.v2-feature-highlight-item');
+ bullets.forEach(item => {
+ const url = item.getAttribute('data-preview-url');
+ if (!url) return;
+
+ item.addEventListener('mouseenter', (e) => {
+ // only show when this slide is active
+ const slide = item.closest('.v2-modal-content');
+ if (!slide || !slide.classList.contains('active')) return;
+
+ // set image
+ globalTooltip.style.backgroundImage = `url('${url}')`;
+
+ // position tooltip near the bullet
+ const rect = item.getBoundingClientRect();
+ const tooltipW = 320; // matches CSS
+ const tooltipH = 190;
+
+ // position above the bullet and biased to the right (top-right)
+ let top = Math.round(rect.top - tooltipH - 12);
+ // bias the tooltip to the right side of the bullet (so its left edge sits near the bullet's right)
+ let left = Math.round(rect.right - Math.round(tooltipW * 0.15));
+
+ // clamp into viewport (prefer to shift left if overflowing right)
+ const padding = 12;
+ const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
+ if (left + tooltipW + padding > vw) {
+ left = vw - tooltipW - padding;
+ }
+ if (left < padding) left = padding;
+
+ // clamp top into viewport (if not enough room above, place below the bullet)
+ const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
+ if (top < padding) {
+ // fallback: place below the bullet
+ top = rect.bottom + 12;
+ if (top + tooltipH + padding > vh) top = Math.max(padding, vh - tooltipH - padding);
+ }
+
+ globalTooltip.style.left = `${Math.round(left)}px`;
+ globalTooltip.style.top = `${Math.round(top)}px`;
+
+ // show with animation
+ globalTooltip.classList.add('visible');
+ });
+
+ item.addEventListener('mouseleave', () => {
+ globalTooltip.classList.remove('visible');
+ });
+ });
+ }
+
+ /* Slide auto-advance timer and progress animation */
+ startSlideTimer() {
+ if (this.prefersReducedMotion) return;
+ this.clearSlideTimer();
+ // start visual progress on active dot
+ this.animateProgressForDot(this.currentStep);
+
+ // don't auto-advance if we're already on the last step
+ if (this.currentStep >= this.totalSteps) {
+ return;
+ }
+
+ // start a timer to advance slides
+ this.slideTimer = setTimeout(() => {
+ if (this.modalShown) {
+ this.nextStep();
+ this.startSlideTimer();
+ }
+ }, this.autoAdvanceInterval);
+ }
+
+ clearSlideTimer() {
+ if (this.slideTimer) {
+ clearTimeout(this.slideTimer);
+ this.slideTimer = null;
+ }
+ // remove any progress-fill elements
+ const fills = document.querySelectorAll('.v2-progress-fill');
+ fills.forEach(f => f.remove());
+ }
+
+ restartSlideTimer() {
+ this.clearSlideTimer();
+ if (this.modalShown && !this.prefersReducedMotion) {
+ // small delay to allow UI updates before starting animation
+ setTimeout(() => this.startSlideTimer(), 80);
+ }
+ }
+
+ animateProgressForDot(stepNumber) {
+ if (this.prefersReducedMotion) return;
+ const dots = document.querySelectorAll('.v2-progress-dot');
+ dots.forEach((dot, idx) => {
+ // ensure relative positioning for fill
+ dot.style.position = 'relative';
+ // remove existing fill
+ const existing = dot.querySelector('.v2-progress-fill');
+ if (existing) existing.remove();
+ // only add fill to active dot
+ if (idx + 1 === stepNumber) {
+ const fill = document.createElement('span');
+ fill.className = 'v2-progress-fill';
+ // initial styles
+ fill.style.position = 'absolute';
+ fill.style.left = '0';
+ fill.style.top = '0';
+ fill.style.height = '100%';
+ fill.style.width = '0%';
+ fill.style.borderRadius = '999px';
+ fill.style.background = 'linear-gradient(90deg, rgba(124,58,237,0.9), rgba(99,102,241,0.9))';
+ fill.style.zIndex = '0';
+ fill.style.transition = `width ${this.autoAdvanceInterval}ms linear`;
+ dot.appendChild(fill);
+ // force layout then animate to full width
+ // expand to match the dot's computed width
+ requestAnimationFrame(() => {
+ // ensure the dot has been sized (active dot may be wider)
+ const targetW = dot.clientWidth + 'px';
+ // use percentage to fill entire dot
+ fill.style.width = '100%';
+ });
+ }
+ });
+ }
+
+ closeModal() {
+ const overlay = document.getElementById('v2-modal-overlay');
+ if (overlay) {
+ overlay.classList.remove('active');
+ document.body.style.overflow = '';
+
+ // Mark as seen
+ localStorage.setItem('v2_announcement_seen', 'true');
+
+ // Hide badge
+ const badge = document.getElementById('v2-badge');
+ if (badge) {
+ badge.style.display = 'none';
+ }
+ }
+ this.modalShown = false;
+ // clear timers and progress
+ this.clearSlideTimer();
+ }
+
+ showStep(stepNumber) {
+ // Validate step number
+ if (stepNumber < 1 || stepNumber > this.totalSteps) return;
+
+ // Hide all steps
+ const allSteps = document.querySelectorAll('.v2-modal-content');
+ allSteps.forEach(step => step.classList.remove('active'));
+
+ // Show current step
+ const currentStepEl = document.getElementById(`v2-step-${stepNumber}`);
+ if (currentStepEl) {
+ currentStepEl.classList.add('active');
+ }
+
+ // Update progress dots
+ this.updateProgressDots(stepNumber);
+
+ this.currentStep = stepNumber;
+ // restart auto-advance timer and progress animation when step changes
+ this.restartSlideTimer();
+ }
+
+ updateProgressDots(activeStep) {
+ const dots = document.querySelectorAll('.v2-progress-dot');
+ dots.forEach((dot, index) => {
+ if (index + 1 === activeStep) {
+ dot.classList.add('active');
+ // ensure active dot has progress animation started
+ this.animateProgressForDot(activeStep);
+ } else {
+ dot.classList.remove('active');
+ }
+ });
+ }
+
+ nextStep() {
+ if (this.currentStep < this.totalSteps) {
+ this.showStep(this.currentStep + 1);
+ }
+ }
+
+ prevStep() {
+ if (this.currentStep > 1) {
+ this.showStep(this.currentStep - 1);
+ }
+ }
+
+ fireConfetti() {
+ const runSequence = () => {
+ const confettiInstance = this.getConfettiInstance();
+ if (!confettiInstance) {
+ console.warn('[V2Announcement] Confetti instance missing even though library is present');
+ return;
+ }
+
+ const count = 200;
+ const defaults = {
+ origin: { y: 0.7 }
+ };
+
+ const fire = (particleRatio, opts) => {
+ confettiInstance({
+ ...defaults,
+ ...opts,
+ particleCount: Math.floor(count * particleRatio)
+ });
+ };
+
+ fire(0.25, {
+ spread: 26,
+ startVelocity: 55,
+ });
+
+ setTimeout(() => {
+ fire(0.2, {
+ spread: 60,
+ });
+ }, 100);
+
+ setTimeout(() => {
+ fire(0.35, {
+ spread: 100,
+ decay: 0.91,
+ scalar: 0.8
+ });
+ }, 200);
+
+ setTimeout(() => {
+ fire(0.1, {
+ spread: 120,
+ startVelocity: 25,
+ decay: 0.92,
+ scalar: 1.2
+ });
+ }, 300);
+
+ setTimeout(() => {
+ fire(0.1, {
+ spread: 120,
+ startVelocity: 45,
+ });
+ }, 400);
+ };
+
+ const ensureConfetti = () => {
+ if (typeof confetti !== 'undefined') {
+ return Promise.resolve();
+ }
+
+ if (this.confettiPromise) {
+ return this.confettiPromise;
+ }
+
+ this.confettiPromise = new Promise((resolve, reject) => {
+ const existingScript = document.querySelector('script[src*="canvas-confetti"]');
+
+ if (existingScript) {
+ existingScript.addEventListener('load', () => resolve());
+ existingScript.addEventListener('error', () => reject(new Error('Failed to load confetti script')));
+ return;
+ }
+
+ const script = document.createElement('script');
+ script.src = 'https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.4/dist/confetti.browser.min.js';
+ script.async = true;
+ script.addEventListener('load', () => resolve());
+ script.addEventListener('error', () => reject(new Error('Failed to load confetti script')));
+ document.head.appendChild(script);
+ });
+
+ return this.confettiPromise;
+ };
+
+ ensureConfetti()
+ .then(runSequence)
+ .catch((err) => {
+ console.warn('[V2Announcement] Confetti library not loaded', err);
+ this.confettiPromise = null;
+ });
+ }
+
+ getConfettiInstance() {
+ if (this.confettiInstance) {
+ return this.confettiInstance;
+ }
+
+ if (typeof confetti === 'undefined') {
+ return null;
+ }
+
+ let canvas = document.getElementById('v2-confetti-canvas');
+ if (!canvas) {
+ canvas = document.createElement('canvas');
+ canvas.id = 'v2-confetti-canvas';
+ canvas.style.position = 'fixed';
+ canvas.style.top = '0';
+ canvas.style.left = '0';
+ canvas.style.width = '100%';
+ canvas.style.height = '100%';
+ canvas.style.pointerEvents = 'none';
+ canvas.style.zIndex = '1000000';
+ canvas.style.inset = '0';
+ document.body.appendChild(canvas);
+ }
+
+ this.confettiInstance = confetti.create(canvas, {
+ resize: true,
+ useWorker: true
+ });
+
+ return this.confettiInstance;
+ }
+}
+
+// Initialize when DOM is ready
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => {
+ new V2Announcement();
+ });
+} else {
+ new V2Announcement();
+}
diff --git a/static/js/verification-check.js b/static/js/verification-check.js
new file mode 100644
index 00000000..39ea9fdd
--- /dev/null
+++ b/static/js/verification-check.js
@@ -0,0 +1,312 @@
+/**
+ * Email Verification Frontend Enforcement
+ * Checks JWT claims for email_verified status and blocks resource creation for unverified users
+ */
+
+// Get email verification status from g object (passed from backend)
+function isEmailVerified() {
+ // Check if g object exists and has jwt_claims with email_verified
+ if (typeof g !== 'undefined' && g.jwt_claims && typeof g.jwt_claims.email_verified === 'boolean') {
+ return g.jwt_claims.email_verified;
+ }
+ // Default to true if we can't determine (fail open for better UX)
+ return true;
+}
+
+// Show verification required modal
+function showVerificationModal(action = "perform this action") {
+ // Create modal HTML with unique class names to avoid conflicts
+ const modalHTML = `
+
+
+
+
+
+
+
+
+
You need to verify your email address to ${action}.
+
We've sent a verification code to your email. Please check your inbox and verify your account.
+
+
+
+
+
+
+
+ `;
+
+ // Remove existing modal if present
+ const existingModal = document.getElementById('verificationModal');
+ if (existingModal) {
+ existingModal.remove();
+ }
+
+ // Add modal to DOM
+ document.body.insertAdjacentHTML('beforeend', modalHTML);
+
+ // Trigger animation by adding active class after a brief delay
+ requestAnimationFrame(() => {
+ const modal = document.getElementById('verificationModal');
+ if (modal) {
+ requestAnimationFrame(() => {
+ modal.classList.add('active');
+ });
+ }
+ });
+
+ // Add modal-specific styles if not already present
+ if (!document.getElementById('verificationModalStyles')) {
+ const styles = document.createElement('style');
+ styles.id = 'verificationModalStyles';
+ styles.textContent = `
+ /* Email Verification Modal - Standalone styles */
+ .email-verification-modal {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 99999;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1), visibility 0.4s cubic-bezier(0.4, 0, 0.2, 1);
+ }
+
+ .email-verification-modal.active {
+ display: flex;
+ opacity: 1;
+ visibility: visible;
+ }
+
+ .email-verification-backdrop {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(10px) saturate(180%) brightness(0.7);
+ -webkit-backdrop-filter: blur(10px) saturate(180%) brightness(0.7);
+ }
+
+ .email-verification-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ padding: 20px;
+ position: relative;
+ z-index: 2;
+ }
+
+ .email-verification-content {
+ backdrop-filter: blur(60px);
+ -webkit-backdrop-filter: blur(60px);
+ background: rgba(15, 20, 35, 0.5);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 16px;
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
+ width: 100%;
+ max-width: 500px;
+ overflow: hidden;
+ transform: scale(0.85) translateY(20px);
+ opacity: 0;
+ transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
+ }
+
+ .email-verification-modal.active .email-verification-content {
+ transform: scale(1) translateY(0);
+ opacity: 1;
+ }
+
+ .email-verification-header {
+ padding: 20px 24px 16px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.02);
+ }
+
+ .email-verification-title-section {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ }
+
+ .email-verification-icon {
+ width: 48px;
+ height: 48px;
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 24px;
+ flex-shrink: 0;
+ background: linear-gradient(135deg, #7c3aed, #6d28d9);
+ color: white;
+ }
+
+ .email-verification-title {
+ font-size: 24px;
+ font-weight: 600;
+ color: var(--text-primary, #ffffff);
+ margin: 0 0 4px 0;
+ line-height: 1.2;
+ }
+
+ .email-verification-subtitle {
+ font-size: 14px;
+ color: var(--text-secondary, rgba(255, 255, 255, 0.6));
+ margin: 0;
+ line-height: 1.4;
+ }
+
+ .email-verification-body {
+ padding: 24px;
+ }
+
+ .email-verification-text p {
+ color: var(--text-secondary, rgba(255, 255, 255, 0.7));
+ line-height: 1.6;
+ margin: 0 0 16px 0;
+ font-size: 14px;
+ }
+
+ .email-verification-text p:last-child {
+ margin-bottom: 0;
+ }
+
+ .email-verification-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ padding: 16px 24px;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.02);
+ }
+
+ .email-verification-btn {
+ padding: 10px 20px;
+ border-radius: 8px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ border: none;
+ text-decoration: none;
+ font-family: inherit;
+ }
+
+ .email-verification-btn-secondary {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--text-primary, #ffffff);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ }
+
+ .email-verification-btn-secondary:hover {
+ background: rgba(255, 255, 255, 0.12);
+ border-color: rgba(255, 255, 255, 0.2);
+ }
+
+ .email-verification-btn-primary {
+ background: #7c3aed;
+ color: white;
+ border: 1px solid #7c3aed;
+ }
+
+ .email-verification-btn-primary:hover {
+ background: #6d28d9;
+ border-color: #6d28d9;
+ }
+
+ @media (max-width: 768px) {
+ .email-verification-content {
+ max-width: 95vw;
+ margin: 20px;
+ }
+
+ .email-verification-title {
+ font-size: 20px;
+ }
+
+ .email-verification-icon {
+ width: 40px;
+ height: 40px;
+ font-size: 20px;
+ }
+ }
+ `;
+ document.head.appendChild(styles);
+ }
+}
+
+// Close verification modal
+function closeVerificationModal() {
+ const modal = document.getElementById('verificationModal');
+ if (modal) {
+ modal.classList.remove('active');
+ setTimeout(() => modal.remove(), 400);
+ }
+}
+
+// Check verification before creating a short URL
+function checkVerificationBeforeShorten() {
+ if (!isEmailVerified()) {
+ showVerificationModal("create short URLs");
+ return false;
+ }
+ return true;
+}
+
+// Check verification before creating an API key
+function checkVerificationBeforeAPIKey() {
+ if (!isEmailVerified()) {
+ showVerificationModal("create API keys");
+ return false;
+ }
+ return true;
+}
+
+// Generic verification check
+function checkVerification(action = "perform this action") {
+ if (!isEmailVerified()) {
+ showVerificationModal(action);
+ return false;
+ }
+ return true;
+}
+
+// Close modal when clicking backdrop
+document.addEventListener('click', function(e) {
+ if (e.target.classList.contains('email-verification-backdrop')) {
+ closeVerificationModal();
+ }
+});
+
+// Close modal with Escape key
+document.addEventListener('keydown', function(e) {
+ if (e.key === 'Escape') {
+ closeVerificationModal();
+ }
+});
diff --git a/templates/api.html b/templates/api.html
index 31e821e0..d6c74a32 100644
--- a/templates/api.html
+++ b/templates/api.html
@@ -134,7 +134,7 @@
-
Spoo.me Api Documentation
@@ -2095,7 +2095,7 @@
📦 Spoo.me Python Library
-
Complete documentation for the library can be found Complete documentation for the library can be found here .
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 00000000..efad2332
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
{% block title %}spoo.me{% endblock %}
+
+ {% block meta %}{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% block head_css %}{% endblock %}
+
+
+
+ {% block setup %}{% endblock %}
+ {% include 'partials/self_promo.html' %}
+ {% include 'partials/navbar.html' %}
+ {% include 'partials/mobile_navbar.html' %}
+ {% include 'partials/contact_modal.html' %}
+ {% include 'partials/auth_modal.html' %}
+ {% include 'partials/v2_announcement.html' %}
+
+ {% block pre_body %}{% endblock %}
+
+
+ {% block content %}{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% block scripts %}{% endblock %}
+
+
+
\ No newline at end of file
diff --git a/templates/contact.html b/templates/contact.html
index 5f096e86..30e26b5b 100644
--- a/templates/contact.html
+++ b/templates/contact.html
@@ -1,192 +1,75 @@
-
-
+{% extends "base.html" %}
-
-
-
Contact us - spoo.me
-
-
-
-
+{% block title %}Contact us - spoo.me{% endblock %}
-
+{% block meta %}
+
+
-
-
-
+
-
+
-
+{% endblock %}
+{% block head_css %}
-
-
-
-
-
-
-
-
-
- {% if self_promo %}
-
- {% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+{% endblock %}
+{% block content %}
Contact us
-
-
or connect with us via
-
+{% endblock %}
-
-
-
-
-
-
-
-
-
-
+{% block scripts %}
+
-
-
-
-
\ No newline at end of file
+{% endblock %}
\ No newline at end of file
diff --git a/templates/dashboard/base.html b/templates/dashboard/base.html
new file mode 100644
index 00000000..f8469a0e
--- /dev/null
+++ b/templates/dashboard/base.html
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
{% block title %}Dashboard | spoo.me{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% block head_css %}{% endblock %}
+
+
+
+
+
+
+
+ {% include 'dashboard/partials/sidebar.html' %}
+
+
+ {% include 'dashboard/partials/verification_banner.html' %}
+
+ {% block content %}{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% block scripts %}{% endblock %}
+
+
+
\ No newline at end of file
diff --git a/templates/dashboard/billing.html b/templates/dashboard/billing.html
new file mode 100644
index 00000000..e89c41bf
--- /dev/null
+++ b/templates/dashboard/billing.html
@@ -0,0 +1,110 @@
+{% extends "dashboard/base.html" %}
+
+{% block title %}Billing | Dashboard | spoo.me{% endblock %}
+
+{% block head_css %}
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+ Billing Cycle
+ Free Forever
+
+
+
+
+
+ Payment Method
+ No payment required
+
+
+
+
+
+
What's Included
+
+
+
+
+ Unlimited Links
+ Create as many short links as you need
+
+
+
+
+
+ Analytics
+ Track clicks and basic statistics
+
+
+
+
+
+ Custom Aliases
+ Create memorable short URLs
+
+
+
+
+
+ Password Protection
+ Secure your links with passwords
+
+
+
+
+
+ Expiration Control
+ Set expiration dates for your links
+
+
+
+
+
+ API Access
+ Integrate with our REST API
+
+
+
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/templates/dashboard/keys.html b/templates/dashboard/keys.html
new file mode 100644
index 00000000..62d412bb
--- /dev/null
+++ b/templates/dashboard/keys.html
@@ -0,0 +1,272 @@
+{% extends "dashboard/base.html" %}
+
+{% block title %}API Keys | Dashboard | spoo.me{% endblock %}
+
+{% block head_css %}
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
Loading…
+
No API keys yet. Create your first key to get started.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Never expose this key in frontend or client-side code
+
+
+
+ Store it securely using environment variables
+
+
+
+ Rotate if you suspect exposure
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Delete
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/templates/dashboard/links.html b/templates/dashboard/links.html
new file mode 100644
index 00000000..6b26fc4d
--- /dev/null
+++ b/templates/dashboard/links.html
@@ -0,0 +1,1342 @@
+{% extends "dashboard/base.html" %}
+
+{% block title %}Links | Dashboard | spoo.me{% endblock %}
+
+{% block head_css %}
+
+
+
+
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
Loading…
+
+
+
+
+
+
Oops! Nothing here yet
+
Looks like you haven't created any links yet, or your search didn't match
+ anything.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Basic
+
+
+
+ Security
+
+
+
+ Advanced
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Your shortened link
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Download
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Basic
+
+
+
+ Security
+
+
+
+ Advanced
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
You are about to permanently delete this short URL:
+
+
+
+
To confirm deletion, please type the short URL alias below:
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/templates/dashboard/partials/sidebar.html b/templates/dashboard/partials/sidebar.html
new file mode 100644
index 00000000..50c6a3c9
--- /dev/null
+++ b/templates/dashboard/partials/sidebar.html
@@ -0,0 +1,82 @@
+
\ No newline at end of file
diff --git a/templates/dashboard/partials/verification_banner.html b/templates/dashboard/partials/verification_banner.html
new file mode 100644
index 00000000..4f0ef885
--- /dev/null
+++ b/templates/dashboard/partials/verification_banner.html
@@ -0,0 +1,172 @@
+{% if user and not user.email_verified %}
+
+
+
⚠️
+
+
Verify your email to unlock all features
+
Check {{ user.email }} for your 6-digit verification code
+
+
+
+
+
+
+
+
+
+
+{% endif %}
diff --git a/templates/dashboard/settings.html b/templates/dashboard/settings.html
new file mode 100644
index 00000000..5df94438
--- /dev/null
+++ b/templates/dashboard/settings.html
@@ -0,0 +1,1754 @@
+{% extends "dashboard/base.html" %}
+
+{% block title %}Settings | Dashboard | spoo.me{% endblock %}
+
+{% block head_css %}
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+ Display Name
+ {{ user.user_name or 'Not set' }}
+
+
+ Email
+ {{ user.email }}
+
+
+ Account Type
+ {{ user.plan|capitalize }}
+
+
+
+
+
+
+
+
+
+
+
+ Save Picture
+
+
+
+
+
+
+
+
+ Password Authentication
+
+ {% if user.password_set %}
+ Enabled
+ {% else %}
+ Not Set
+ Set Password
+ {% endif %}
+
+
+
+ Email Verification
+
+ {% if user.email_verified %}
+ Verified
+ {% else %}
+ Unverified
+ {% endif %}
+
+
+
+ Two-Factor Authentication
+ Coming Soon
+
+
+ Session Management
+ Coming Soon
+
+
+
+
+
+
+
+
+ Email Notifications
+ Coming Soon
+
+
+ Weekly Reports
+ Coming Soon
+
+
+ Link Alerts
+ Coming Soon
+
+
+
+
+
+
+ Default Link Expiry
+ Coming Soon
+
+
+ Custom Domain
+ Coming Soon
+
+
+ Export Data
+ Coming Soon
+
+
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/templates/dashboard/statistics.html b/templates/dashboard/statistics.html
new file mode 100644
index 00000000..3d394f0a
--- /dev/null
+++ b/templates/dashboard/statistics.html
@@ -0,0 +1,566 @@
+{% extends "dashboard/base.html" %}
+
+{% block title %}Statistics Dashboard{% endblock %}
+
+{% block head_css %}
+
+
+
+
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+ Filters
+ 0
+
+
+
+
+
+
+
+
+
+
+
+ Browsers
+
+
+ All
+
+
+
+
+
+
+
+
+
+ Operating Systems
+
+
+ All
+
+
+
+
+
+
+
+
+
+
+
+ Countries
+
+
+ All
+
+
+
+
+
+
+
+
+
+ Cities
+
+
+ All
+
+
+
+
+
+
+
+
+
+ Referrers
+
+
+ All
+
+
+
+
+
+
+
+
+
+ Short URLs
+
+
+ All
+
+
+
+
+
+
+
+
+
+ Clear All Filters
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Off
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0
+
Avg Redirection Time
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/templates/emails/password_reset.html b/templates/emails/password_reset.html
new file mode 100644
index 00000000..6a76e657
--- /dev/null
+++ b/templates/emails/password_reset.html
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Your spoo.me password reset code: {{ otp_code }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Reset Your Password
+
+ Hello{% if user_name %}, {{ user_name }}{% endif %},
+
+ We received a request to reset your password for your spoo.me account. Please
+ use the verification code below to proceed with the password reset:
+
+
+
+
+
+
+
+
+
+
+ Enter this code in the password reset field to create a new password. This code
+ will expire in 10 minutes for security purposes.
+
+ ⚠️ Security Alert: If you didn't request this password reset, please secure your
+ account immediately by changing your password or contacting our support team.
+
+
+
+
+
+
+
+
+
+
+
+
+ Need help? Contact us at support@spoo.me
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ © 2025 spoo.me. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/emails/verification.html b/templates/emails/verification.html
new file mode 100644
index 00000000..25590bb1
--- /dev/null
+++ b/templates/emails/verification.html
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Your spoo.me verification code: {{ otp_code }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Verify Your Account
+
+ Hello{% if user_name %}, {{ user_name }}{% endif %},
+
+ We received a request to verify your email address for your spoo.me account.
+ Please use the verification code below to complete the process:
+
+
+
+
+
+
+
+
+
+
+ Enter this code in the verification field to complete your account setup. This
+ code will expire in 10 minutes for security purposes.
+
+ If you didn't request this verification, please ignore this email or contact our
+ support team if you have concerns.
+
+
+
+
+
+
+
+
+
+
+
+ Need help? Contact us at support@spoo.me
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ © 2025 spoo.me. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/emails/welcome.html b/templates/emails/welcome.html
new file mode 100644
index 00000000..6c71f62c
--- /dev/null
+++ b/templates/emails/welcome.html
@@ -0,0 +1,272 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Welcome to spoo.me - Your URL shortening journey starts here!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🎉 Welcome to spoo.me, {{ user_name }}!
+
+ The modern URL shortener designed for developers, businesses, and power users
+ who demand more from their links.
+
+
+
+
+
+
+
+
+
+ What makes spoo.me different?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Powerful Analytics
+
+ Track clicks, locations, devices, and referrers with detailed
+ insights into how your links perform.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Developer-Friendly API
+
+ Integrate spoo.me into your applications with our comprehensive
+ REST API and detailed documentation.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Enterprise Security
+
+ Password protection, expiration dates, and private links keep
+ your URLs secure and under your control.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Link Management
+
+ Edit, update, or delete your shortened URLs anytime. Full
+ control over your link portfolio in one place.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ready to get started?
+
+ Create your first short link from the dashboard
+
+ Customize your link with a memorable alias
+ Share it and watch the analytics roll in
+ Explore our API documentation for advanced features
+
+
+
+
+
+
+
+
+
+
+
+ Need help? Contact us at support@spoo.me
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ © 2025 spoo.me. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/error.html b/templates/error.html
index cb6a68c9..1002d247 100644
--- a/templates/error.html
+++ b/templates/error.html
@@ -1,127 +1,37 @@
-
-
+{% extends "base.html" %}
-
-
-
-
-
+{% block title %}Error{% endblock %}
-
+{% block meta %}
+
-
-
+{% endblock %}
-
Error
-
-
-
+{% block head_css %}
-
-
-
-
-
-
- {% if self_promo %}
-
- {% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+{% endblock %}
+{% block content %}
+
-
- {{ error_code }}, {{ error_message }}
-
+
{{ error_code }}, {{ error_message }}
-
+{% endblock %}
-
-
-
+{% block scripts %}
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+{% endblock %}
\ No newline at end of file
diff --git a/templates/index.html b/templates/index.html
index c723288d..a2a1b9e4 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -1,8 +1,8 @@
-
-
+{% extends "base.html" %}
-
-
+{% block title %}spoo.me URL Shortener{% endblock %}
+
+{% block meta %}
-
-
spoo.me URL Shortener
-
-
-
-
+
-
-
-
+
-
-
-
+
-
-
+{% endblock %}
+{% block head_css %}
-
-
-
-
-
-
-
-
-
-
- {% if self_promo %}
-
- {% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+{% endblock %}
+{% block content %}
@@ -198,26 +95,7 @@
- {% if recentURLs %}
-
{% for url in recentURLs %}
{% endfor %}
- {% endif %}
+
@@ -244,34 +122,11 @@
ONLINE IN DISCORD
+{% endblock %}
-
-
-
-
+{% block scripts %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/templates/legal/privacy-policy.html b/templates/legal/privacy-policy.html
index bb08410d..85399f0f 100644
--- a/templates/legal/privacy-policy.html
+++ b/templates/legal/privacy-policy.html
@@ -3,19 +3,61 @@
{% block title %}Privacy Policy - spoo.me{% endblock %}
{% block content %}
-
Privacy Policy
At Spoo.me, accessible from https://spoo.me , one of our main priorities is the
- privacy of our visitors. This
- Privacy Policy document contains types of information that is collected and recorded by Spoo.me and how we
- use
- it.
+ privacy of our visitors. This Privacy Policy document contains types of information that is collected and
+ recorded by Spoo.me and how we use it.
If you have additional questions or require more information about our Privacy Policy, do not hesitate to
- contact us.
+ contact us at support@spoo.me .
+
+
+
+ Information We Collect
+
+
+
+ Account Information
+
+ When you create an account on Spoo.me, we collect:
+
+ Email address: Used for account creation, verification, and communication
+ Display name: Your chosen username for the platform
+ Password: Stored as a secure hash (we never store plain text passwords)
+ Profile picture: If provided through OAuth authentication
+
+
+ Authentication Data
+
+ We offer multiple authentication methods:
+
+ Email/Password Authentication: We store your email and password hash securely
+ OAuth Authentication: When you sign in with Google, GitHub, or Discord, we receive your email address, display name, and profile picture from these providers. We do not receive or store your passwords for these services
+ Session Cookies: Authentication cookies are used to maintain your logged-in session
+ Email Verification: We send verification codes to confirm your email address
+
+
+ User-Generated Content
+
+ When you use our services, we store:
+
+ Shortened URLs: The URLs you create, including custom aliases and target destinations
+ API Keys: Keys you generate for programmatic access to our API
+ URL Metadata: Creation dates, expiration settings, and password protection preferences
+
+
+ Usage Data and Analytics
+
+ We collect data about how you interact with our services:
+
+ Click Analytics: Anonymized data about clicks on your shortened URLs, including geographic location, device type, and referrer information
+ API Usage: Request logs for API calls made with your API keys
+ Service Usage: Feature usage patterns to improve our platform
+
@@ -24,16 +66,15 @@ Log Files
Spoo.me follows a standard procedure of using log files. These files log visitors when they visit websites.
- All
- hosting companies do this and a part of hosting services' analytics. The information collected by log files
- include internet protocol (IP) addresses, browser type, Internet Service Provider (ISP), date and time
- stamp, referring/exit pages, and possibly the number of clicks. These are not linked to any information that
- is personally identifiable. The purpose of the information is for analyzing trends, administering the site,
- tracking users' movement on the website, and gathering demographic information.
+ The information collected by log files includes internet protocol (IP) addresses, browser type, Internet
+ Service Provider (ISP), date and time stamp, referring/exit pages, and possibly the number of clicks. These
+ are not linked to any information that is personally identifiable except when you are logged in to your account.
+ The purpose of the information is for analyzing trends, administering the site, tracking users' movement on
+ the website, and gathering demographic information.
- When you visit a short URL on Spoo.me, we collect your IP address. This data is anonymized and we do not
- associate it with any login information or personally identifiable information. We use this anonymized data
- to analyze trends and improve our service.
+ When you visit a short URL on Spoo.me, we collect your IP address. For anonymous visitors, this data is
+ anonymized. For logged-in users, we may associate this data with your account to provide analytics for
+ your shortened URLs.
@@ -42,10 +83,72 @@ Cookies and Web Beacons
Like any other website, Spoo.me uses 'cookies'. These cookies are used to store information including
- visitors'
- preferences, and the pages on the website that the visitor accessed or visited. The information is used to
- optimize the users' experience by customizing our web page content based on visitors' browser type and/or
- other information.
+ visitors' preferences, and the pages on the website that the visitor accessed or visited. The information is
+ used to optimize the users' experience by customizing our web page content based on visitors' browser type
+ and/or other information.
+
+ Authentication Cookies
+
+ When you log in to your account, we use the following cookies:
+
+ Access Token: A short-lived cookie that maintains your logged-in session (typically expires after 15 minutes)
+ Refresh Token: A longer-lived cookie that allows automatic session renewal (typically expires after 7 days)
+ Session Cookie: Used to maintain your login state across page visits
+
+
+ These cookies are essential for the authentication functionality and cannot be disabled if you wish to use
+ account features. You can delete these cookies by logging out or clearing your browser cookies, which will
+ sign you out of your account.
+
+
+
+ How We Use Your Information
+
+
+
+ We use the collected information for the following purposes:
+
+ Account Management: To create, maintain, and secure your account
+ Service Provision: To provide URL shortening services and API access
+ Analytics: To provide you with statistics about your shortened URLs
+ Communication: To send important service updates, security alerts, and email verification
+ Security: To detect and prevent fraud, abuse, and unauthorized access
+ Service Improvement: To understand usage patterns and improve our platform
+ Legal Compliance: To comply with applicable laws and regulations
+
+
+
+
+ Data Sharing and Third Parties
+
+
+
+ OAuth Providers
+
+ When you authenticate using OAuth (Google, GitHub, or Discord), we receive limited information from these
+ providers as per their privacy policies:
+
+ Google: Email address, profile name, and profile picture
+ GitHub: Email address, username, and profile picture
+ Discord: Email address, username, and profile picture
+
+
+ We do not share your Spoo.me account data back with these OAuth providers beyond what is required for
+ authentication. Please review the privacy policies of these services:
+ Google Privacy Policy ,
+ GitHub Privacy Policy ,
+ Discord Privacy Policy .
+
+ Third-Party Services We Use
+
+ We use the following third-party services that may collect data:
+
+
+ We do not sell your personal data to third parties.
@@ -68,6 +171,114 @@ Privacy Policies
+ Data Security
+
+
+
+ We take the security of your personal information seriously and implement appropriate technical and
+ organizational measures:
+
+ Password Security: All passwords are hashed using industry-standard bcrypt algorithm before storage
+ Encryption: Data transmission is encrypted using HTTPS/TLS
+ Access Controls: Strict access controls limit who can access user data
+ Regular Security Audits: We regularly review our security practices
+ Secure Database: User data is stored in secure, access-controlled databases
+
+
+ However, no method of transmission over the Internet or electronic storage is 100% secure. While we strive
+ to use commercially acceptable means to protect your personal information, we cannot guarantee its absolute
+ security.
+
+
+
+ Data Retention
+
+
+
+ We retain your personal information for as long as necessary to provide our services and comply with legal
+ obligations:
+
+ Account Data: Retained while your account is active and for a reasonable period after account deletion for backup and legal purposes
+ Shortened URLs: Retained indefinitely unless you delete them or they expire based on your settings
+ Analytics Data: Anonymized click data is retained to provide historical statistics
+ Log Files: Server logs are typically retained for 90 days for security and debugging purposes
+ Deleted Accounts: When you delete your account, we remove your personal information within 30 days, though some data may be retained in backups for up to 90 days
+
+
+
+
+ Your Rights (GDPR & CCPA Compliance)
+
+
+
+ Depending on your location, you may have certain rights regarding your personal information:
+
+ Rights for EU Users (GDPR)
+
+ Right to Access: You can request a copy of all personal data we hold about you
+ Right to Rectification: You can update or correct inaccurate personal information
+ Right to Erasure ("Right to be Forgotten"): You can request deletion of your personal data
+ Right to Data Portability: You can request your data in a machine-readable format
+ Right to Restrict Processing: You can request we limit how we use your data
+ Right to Object: You can object to certain types of processing
+ Right to Withdraw Consent: You can withdraw consent for data processing at any time
+
+
+ Rights for California Users (CCPA)
+
+ Right to Know: You can request information about the personal data we collect and how we use it
+ Right to Delete: You can request deletion of your personal information
+ Right to Opt-Out: You can opt-out of the sale of personal information (note: we do not sell personal information)
+ Right to Non-Discrimination: We will not discriminate against you for exercising your privacy rights
+
+
+ How to Exercise Your Rights
+ To exercise any of these rights, you can:
+
+ Access your account settings to update or delete your information
+ Export your data through your dashboard
+ Contact us at support@spoo.me with your request
+
+
+ We will respond to your request within 30 days. We may need to verify your identity before processing certain requests.
+
+ Lawful Basis for Processing (GDPR)
+ We process your personal data based on the following lawful bases:
+
+ Consent: You provide consent when creating an account and accepting this policy
+ Contract: Processing is necessary to provide the services you requested
+ Legitimate Interests: We have legitimate interests in preventing fraud and improving our services
+ Legal Obligation: We may process data to comply with legal requirements
+
+
+
+
+ International Data Transfers
+
+
+
+ Spoo.me is operated from servers that may be located in different countries. By using our service, you
+ acknowledge that your information may be transferred to and processed in countries other than your country
+ of residence. We ensure appropriate safeguards are in place for such transfers in compliance with applicable
+ data protection laws.
+
+
+
+ Email Verification and Communications
+
+
+
+ When you create an account, we send a verification email to confirm your email address. We may also send you:
+
+ Security alerts and notifications
+ Important service updates
+ Responses to your support requests
+
+
+ We do not send marketing emails. All communications are service-related and essential for account operation.
+
+
+
Third Party Privacy Policies
@@ -76,13 +287,10 @@ Third Party Privacy Policies
consult
the respective Privacy Policies of these third-party ad servers for more detailed information. It may
include
- their practices and instructions about how to opt-out of certain options. You may find a complete list of
- these Privacy Policies and their links here: Privacy Policy Links.
+ their practices and instructions about how to opt-out of certain options.
You can choose to disable cookies through your individual browser options. To know more detailed information
- about cookie management with specific web browsers, it can be found at the browsers' respective websites.
- What
- Are Cookies?
+ about cookie management with specific web browsers, it can be found at the browsers' respective websites.
@@ -108,9 +316,10 @@ Personal Data
- We do not collect personal data such as email addresses from our users. The only information we collect is
- the anonymized data mentioned in the "Log Files" and "Analytics" sections of this policy. We value your
- privacy and strive to collect the minimum amount of data necessary to provide our services.
+ We collect personal data necessary to provide our services, including email addresses for account creation,
+ authentication, and communication. All personal data is handled in accordance with this Privacy Policy and
+ applicable data protection laws including GDPR and CCPA. We value your privacy and implement appropriate
+ security measures to protect your personal information.
@@ -127,6 +336,26 @@ Children's Information
contact us immediately and we will do our best efforts to promptly remove such information from our records.
+ To create an account, users must be at least 13 years of age. By creating an account, you confirm that you
+ meet this age requirement.
+
+
+
+ Changes to This Privacy Policy
+
+
+
+ We may update this Privacy Policy from time to time to reflect changes in our practices or for legal,
+ operational, or regulatory reasons. When we make material changes, we will notify you by:
+
+ Updating the "Last Updated" date at the bottom of this policy
+ Sending an email notification to registered users (for significant changes)
+ Displaying a notice on our website
+
+
+ Your continued use of our services after any changes constitutes acceptance of the updated Privacy Policy.
+ We encourage you to review this policy periodically.
+
Online Privacy Policy Only
@@ -144,7 +373,19 @@ Consent
- By using our website, you hereby consent to our Privacy Policy and agree to its Terms and Conditions.
+ By using our website, you hereby consent to our Privacy Policy and agree to its Terms and Conditions. When
+ you create an account, you explicitly consent to the collection and processing of your personal data as
+ described in this policy.
+
+
+
+ Data Protection Officer
+
+
+
+ For questions or concerns regarding data protection and privacy, you can contact our team at
+ support@spoo.me . We are committed to addressing your privacy concerns
+ and ensuring compliance with applicable data protection regulations.
@@ -152,9 +393,8 @@ Update
- This Privacy Policy was last updated on Monday, March 11th, 2024 . If there will be any update,
- amendment, or
- changes to our Privacy Policy then these will be posted on this page.
+ This Privacy Policy was last updated on Saturday, November 16th, 2025 . If there will be any update,
+ amendment, or changes to our Privacy Policy then these will be posted on this page.
@@ -162,7 +402,14 @@ Contact Us
- If you have any questions about this Privacy Policy, you can contact us: support@spoo.me
+ If you have any questions about this Privacy Policy, wish to exercise your data rights, or have concerns
+ about how we handle your personal information, you can contact us:
+
+ Email: support@spoo.me
+ For GDPR-related requests: Please specify "GDPR Request" in your subject line
+ For CCPA-related requests: Please specify "CCPA Request" in your subject line
+
+
+ We will respond to your inquiry within 30 days.
{% endblock %}
diff --git a/templates/legal/terms-of-service.html b/templates/legal/terms-of-service.html
index 757713c7..51b77b27 100644
--- a/templates/legal/terms-of-service.html
+++ b/templates/legal/terms-of-service.html
@@ -27,10 +27,164 @@ Terms of Service
express purpose of meeting the Client's needs in respect of provision of the Company's stated services, in
accordance with and subject to, prevailing law of Netherlands. Any use of the above terminology or other words in
the singular, plural, capitalization and/or he/she or they, are taken as interchangeable and therefore as referring
- to same. Our Terms of Service were created with the help of the Terms & Conditions Generator .
+ to same.
-
+
+
+Account Registration and Eligibility
+
+
+
+To access certain features of spoo.me, you may be required to create an account. When creating an account, you
+ agree to:
+
+
+ Provide accurate, current, and complete information during registration
+ Maintain and promptly update your account information
+ Be at least 13 years of age to create an account
+ Be responsible for maintaining the confidentiality of your account credentials
+ Be responsible for all activities that occur under your account
+ Notify us immediately of any unauthorized access or security breach
+ Not share your account credentials with others
+ Not create multiple accounts to circumvent restrictions or limitations
+
+
+You may create an account using email/password authentication or through third-party OAuth providers (Google,
+ GitHub, Discord). By using OAuth authentication, you agree to the terms and privacy policies of those respective
+ providers.
+
+We reserve the right to refuse service, terminate accounts, or remove content at our sole discretion, including
+ if we believe you have violated these Terms of Service.
+
+
+
+Account Security and Responsibilities
+
+
+
+You are responsible for:
+
+
+ Maintaining the security of your account password and credentials
+ All activities and content posted under your account
+ Ensuring your account is not used for prohibited purposes
+ Complying with all applicable laws when using our services
+ Not attempting to gain unauthorized access to other accounts or our systems
+
+
+We recommend that you:
+
+ Use a strong, unique password for your spoo.me account
+ Enable email verification to secure your account
+ Do not use automated tools to create or manage accounts without our permission
+ Keep your email address up to date for important security notifications
+
+
+
+
+API Usage Terms and Limitations
+
+
+
+When using our API services, you agree to:
+
+
+ Use API keys only for authorized purposes
+ Keep your API keys confidential and secure
+ Respect rate limits and usage quotas
+ Not use the API to create spam, malware, or phishing URLs
+ Not attempt to circumvent API limitations or security measures
+ Not resell or redistribute API access without authorization
+ Not use the API in a way that could damage, disable, or impair our services
+
+
+API rate limits and usage policies:
+
+ We may impose rate limits on API requests to ensure fair usage
+ Excessive or abusive API usage may result in temporary or permanent suspension
+ We reserve the right to modify API features, endpoints, or access at any time
+ API keys may be revoked if they are used in violation of these terms
+
+
+
+
+User Content and Intellectual Property
+
+
+
+When you create shortened URLs on spoo.me:
+
+
+ You retain ownership of the destination URLs and any content you link to
+ You grant spoo.me a non-exclusive license to store and display your shortened URLs
+ You are responsible for ensuring you have the right to share the destination URLs
+ You agree not to create URLs that infringe on intellectual property rights of others
+ Custom aliases you create are subject to our approval and availability
+
+
+spoo.me retains ownership of:
+
+ The spoo.me platform, software, and infrastructure
+ The shortened URL format and routing system
+ Analytics and aggregate usage data
+ Our trademarks, logos, and branding
+
+
+
+
+Account Termination and Suspension
+
+
+
+We may suspend or terminate your account if:
+
+
+ You violate these Terms of Service
+ You engage in abusive or fraudulent behavior
+ You create URLs for prohibited content (malware, phishing, illegal content)
+ Your account is used to spam or harass others
+ You attempt to circumvent security measures or rate limits
+ Your account remains inactive for an extended period (we will notify you first)
+ Required by law or regulatory authority
+
+
+Upon account termination:
+
+ Your access to account features will be immediately revoked
+ Your shortened URLs may be disabled or removed
+ Your API keys will be revoked
+ You may request data export before termination if you voluntarily delete your account
+
+
+You may voluntarily delete your account at any time through your account settings. Account deletion is permanent
+ and cannot be reversed.
+
+
+
+Service Modifications and Availability
+
+
+
+We reserve the right to:
+
+
+ Modify, suspend, or discontinue any aspect of the service at any time
+ Change features, functionality, or pricing with reasonable notice
+ Impose limits on certain features or restrict access to parts of the service
+ Update these Terms of Service (notice will be provided for material changes)
+
+
+We strive to provide reliable service, but we do not guarantee:
+
+ Uninterrupted or error-free service
+ That the service will meet all your specific requirements
+ That all bugs or defects will be corrected
+ Permanent storage of any particular shortened URL
+
+
+Scheduled maintenance and updates will be announced when possible.
+
+
Cookies
@@ -233,4 +387,61 @@ Disclaimer
As long as the website and the information and services on the website are provided free of charge, we will not be
liable for any loss or damage of any nature.
+
+
+Governing Law and Dispute Resolution
+
+
+
+These Terms of Service shall be governed by and construed in accordance with the laws of the Netherlands, without
+ regard to its conflict of law provisions.
+
+Any disputes arising from these terms or your use of spoo.me shall be resolved through:
+
+ First, good faith negotiation between you and spoo.me
+ If negotiation fails, through binding arbitration or competent courts in the Netherlands
+
+
+For EU users, this does not affect your rights under applicable consumer protection laws in your country of
+ residence.
+
+
+
+Severability
+
+
+
+If any provision of these Terms of Service is found to be unenforceable or invalid, that provision will be limited
+ or eliminated to the minimum extent necessary so that these Terms of Service will otherwise remain in full force
+ and effect.
+
+
+
+Contact and Legal Notices
+
+
+
+For questions about these Terms of Service or to report violations, contact us at:
+
+ Email: support@spoo.me
+ For legal notices: Please specify "Legal Notice" in your subject line
+
+
+
+
+Updates to Terms
+
+
+
+These Terms of Service were last updated on Saturday, November 16th, 2025 .
+
+We may update these terms from time to time. When we make material changes, we will notify you by:
+
+ Updating the "last updated" date
+ Sending an email to registered users (for significant changes)
+ Displaying a notice on our website
+
+
+Your continued use of spoo.me after changes take effect constitutes acceptance of the updated terms.
+
{% endblock %}
\ No newline at end of file
diff --git a/templates/partials/auth_modal.html b/templates/partials/auth_modal.html
new file mode 100644
index 00000000..5b7c5e31
--- /dev/null
+++ b/templates/partials/auth_modal.html
@@ -0,0 +1,558 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Google
+
+
+
+
+
+
+ GitHub
+
+
+
+
+
+
+ Discord
+
+
+
+
+ or
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/partials/contact_modal.html b/templates/partials/contact_modal.html
new file mode 100644
index 00000000..b4a9e1e7
--- /dev/null
+++ b/templates/partials/contact_modal.html
@@ -0,0 +1,15 @@
+
+
+
diff --git a/templates/partials/mobile_navbar.html b/templates/partials/mobile_navbar.html
new file mode 100644
index 00000000..129de4d6
--- /dev/null
+++ b/templates/partials/mobile_navbar.html
@@ -0,0 +1,54 @@
+
+
+
+
\ No newline at end of file
diff --git a/templates/partials/navbar.html b/templates/partials/navbar.html
new file mode 100644
index 00000000..fb522112
--- /dev/null
+++ b/templates/partials/navbar.html
@@ -0,0 +1,64 @@
+
\ No newline at end of file
diff --git a/templates/partials/self_promo.html b/templates/partials/self_promo.html
new file mode 100644
index 00000000..0b2945c6
--- /dev/null
+++ b/templates/partials/self_promo.html
@@ -0,0 +1,8 @@
+{% if self_promo %}
+
+{% endif %}
+
+
diff --git a/templates/partials/v2_announcement.html b/templates/partials/v2_announcement.html
new file mode 100644
index 00000000..3c26c233
--- /dev/null
+++ b/templates/partials/v2_announcement.html
@@ -0,0 +1,207 @@
+
+
+ ✨
+ v2 is here!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Welcome to v2!
+
+ We've completely rebuilt spoo.me from the ground up with powerful new features designed to give you more control than ever before.
+
+
+ Show me what's new
+
+
+
+
+
+
+
+
+
+
+
+
Login System & Dashboard
+
+ Create your account and manage all your shortened URLs in one beautiful, intuitive dashboard.
+
+
+
+
+ Secure authentication with email/password
+
+
+
+ OAuth login with Google, GitHub & Discord
+
+
+
+ Centralized dashboard for all your links
+
+
+
+
+
+
+
+
+
+
+
+
+
Advanced Link Management
+
+ Take full control of your links with powerful editing and management capabilities.
+
+
+
+
+ Edit destination URLs anytime
+
+
+
+ Pause or activate links on demand
+
+
+
+ Update passwords and max clicks
+
+
+
+
+
+
+
+
+
+
+
+
+
API Keys & Security
+
+ Generate secure API keys with customizable access levels for programmatic access.
+
+
+
+
+ Multiple API keys per account
+
+
+
+ Granular permission controls
+
+
+
+ Easy key rotation and revocation
+
+
+
+
+
+
+
+
+
+
+
+
+
Enhanced Analytics
+
+ Get deep insights into your link performance with our completely revamped analytics dashboard.
+
+
+
+
+ Time-based filtering & date ranges
+
+
+
+ Browser, OS & device breakdowns
+
+
+
+ Geographic location insights
+
+
+
+
+
+
+
+
+
+
+
+
+
Next-Gen API Platform
+
+ Meet API v1, redesigned for builders with clearer observability, and a dedicated developer experience.
+
+
+
+
+ 60 req/sec burst + larger daily quotas per key
+
+
+
+ Unified endpoints for shorten, edit, stats & exports
+
+
+
+ Fine-grained environment keys
+
+
+
+
+
+
+
+
+
+
+
Ready to Get Started?
+
+ Sign up now to unlock all these powerful features and take complete control of your shortened links!
+
+
+
+
+
+ Sign Up / Login
+
+
+ Continue as guest
+
+
+
+
+
+
+
+
diff --git a/templates/report.html b/templates/report.html
index ea8a9c2e..64b20d3f 100644
--- a/templates/report.html
+++ b/templates/report.html
@@ -1,208 +1,60 @@
-
-
+{% extends "base.html" %}
-
-
- Report URL - spoo.me
-
-
-
-
+{% block title %}Report URL - spoo.me{% endblock %}
-
+{% block meta %}
+
+
-
-
-
+
-
+
-
+{% endblock %}
+{% block head_css %}
-
-
-
-
-
-
-
-
-
-
- {% if self_promo %}
-
- {% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+{% endblock %}
+{% block content %}
Report URL
-
-
+
-
- {% if error %}
-
- {% endif %}
-
- {% if success %}
-
- {% endif %}
-
+ {% if error %}{% endif %}
+ {% if success %}{% endif %}
-
{{ reason }}
-
- Report
-
+ Report
+{% endblock %}
-
-
-
-
-
-
-
-
-
-
-
+{% block scripts %}
+
-
-
-
-
\ No newline at end of file
+{% endblock %}
\ No newline at end of file
diff --git a/templates/result.html b/templates/result.html
index 47893fb5..27322d7b 100644
--- a/templates/result.html
+++ b/templates/result.html
@@ -1,185 +1,57 @@
-
-
+{% extends "base.html" %}
-
-
- URL Shortener Result
-
-
+{% block title %}URL Shortener Result{% endblock %}
+{% block head_css %}
-
-
-
-
-
+
-
-
-
-
-
-
- {% if self_promo %}
-
- {% endif %}
-
-
-
-
+{% endblock %}
-
-
-
-
-
-
-
-
-
-
-
-
-
+{% block setup %}{% if self_promo %}{% set self_promo_style = 'background-color: #1b1717' %}{% endif %}{% endblock %}
+{% block content %}
-
-
+
Download
-
+{% endblock %}
+{% block scripts %}
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+{% endblock %}
\ No newline at end of file
diff --git a/templates/stats.html b/templates/stats.html
index cb794955..78b48eca 100644
--- a/templates/stats.html
+++ b/templates/stats.html
@@ -1,224 +1,73 @@
-
-
+{% extends "base.html" %}
-
-
- spoo.me URL Stats
-
-
-
-
+{% block title %}spoo.me URL Stats{% endblock %}
-
+{% block meta %}
+
+
-
+
-
-
-
+
-
+{% endblock %}
+{% block head_css %}
-
-
-
-
-
-
+{% endblock %}
-
-
-
- {% if self_promo %}
-
- {% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+{% block content %}
-
{% if error %}
-
+
{% elif password_error %}
-
+
{% endif %}
-
Enter the alias of the short URL
-
-
{% if password_error %}
-
-
+
{% else %}
-
-
+
{% endif %}
-
+{% endblock %}
+{% block scripts %}
-
-
-
{% if error %}
-
+
{% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+{% endblock %}
\ No newline at end of file
diff --git a/templates/stats_view.html b/templates/stats_view.html
index 35b11739..55278e8a 100644
--- a/templates/stats_view.html
+++ b/templates/stats_view.html
@@ -1,125 +1,34 @@
-
-
-
-
-
- Advanced URL Statistics - Spoo.me
-
-
-
-
+{% extends "base.html" %}
+
+{% block title %}Advanced URL Statistics - Spoo.me{% endblock %}
-
+{% block meta %}
+
+
-
+
-
-
-
+
-
+{% endblock %}
-
-
+{% block head_css %}
-
-
-
+{% endblock %}
-
-
-
- {% if self_promo %}
-
- {% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+{% block content %}
@@ -279,17 +188,19 @@
Export Data
+{% endblock %}
-
-
+{% block scripts %}
+
-
+
+
{% if json_data["expired"] %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+{% endblock %}
\ No newline at end of file
diff --git a/templates/verify.html b/templates/verify.html
new file mode 100644
index 00000000..8fed2e79
--- /dev/null
+++ b/templates/verify.html
@@ -0,0 +1,478 @@
+
+
+
+
+
+
+
Verify Email - Spoo.me
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/utils/aggregation_strategies.py b/utils/aggregation_strategies.py
new file mode 100644
index 00000000..ebeafa5e
--- /dev/null
+++ b/utils/aggregation_strategies.py
@@ -0,0 +1,485 @@
+from abc import ABC, abstractmethod
+from typing import List, Dict, Any, Optional
+from datetime import datetime
+from zoneinfo import ZoneInfo
+from utils.analytics_utils import convert_country_name
+from utils.time_bucket_utils import (
+ get_optimal_bucket_config,
+ create_mongo_time_bucket_pipeline,
+ format_time_bucket_display,
+ fill_missing_buckets,
+)
+
+import logging
+
+log = logging.getLogger(__name__)
+
+
+class AggregationStrategy(ABC):
+ """Abstract base class for aggregation strategies"""
+
+ @abstractmethod
+ def build_pipeline(self, base_query: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """Build aggregation pipeline for this strategy"""
+ pass
+
+ @abstractmethod
+ def format_results(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ """Format the aggregation results"""
+ pass
+
+ @property
+ @abstractmethod
+ def dimension_name(self) -> str:
+ """Get the dimension name for this strategy"""
+ pass
+
+
+class TimeAggregationStrategy(AggregationStrategy):
+ """Strategy for time-based aggregation with dynamic bucketing"""
+
+ def __init__(
+ self,
+ start_date: Optional[datetime] = None,
+ end_date: Optional[datetime] = None,
+ time_format: Optional[str] = None,
+ timezone: str = "UTC",
+ ):
+ """
+ Initialize time aggregation strategy.
+
+ Args:
+ start_date: Start date for determining optimal bucket strategy
+ end_date: End date for determining optimal bucket strategy
+ time_format: Manual override for time format (legacy support)
+ timezone: IANA timezone for output formatting (default: UTC)
+ """
+ self.start_date = start_date
+ self.end_date = end_date
+ self.timezone = timezone
+
+ # Determine bucket configuration
+ if time_format:
+ # Legacy mode: use provided format
+ self.bucket_config = None
+ self.time_format = time_format
+ else:
+ # Dynamic mode: determine optimal bucketing
+ self.bucket_config = get_optimal_bucket_config(start_date, end_date)
+ self.time_format = self.bucket_config.mongo_format
+
+ def build_pipeline(self, base_query: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """Build aggregation pipeline with dynamic time bucketing"""
+
+ if self.bucket_config:
+ # Use dynamic bucketing with specialized pipeline and timezone support
+ time_bucket_expr = create_mongo_time_bucket_pipeline(
+ self.bucket_config, timezone=self.timezone
+ )
+ else:
+ # Legacy mode: use simple dateToString with timezone
+ time_bucket_expr = {
+ "$dateToString": {
+ "format": self.time_format,
+ "date": "$clicked_at",
+ "timezone": self.timezone,
+ }
+ }
+
+ return [
+ {"$match": base_query},
+ {
+ "$group": {
+ "_id": time_bucket_expr,
+ "total_clicks": {"$sum": 1},
+ "unique_clicks": {"$addToSet": "$ip_address"},
+ }
+ },
+ {"$addFields": {"unique_clicks": {"$size": "$unique_clicks"}}},
+ {"$sort": {"_id": 1}},
+ ]
+
+ def format_results(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ """Format results with proper time bucket display and fill missing buckets"""
+ formatted_results = []
+
+ for result in results:
+ bucket_value = result["_id"]
+
+ # Format the time bucket for display if using dynamic bucketing
+ # NOTE: Buckets are already in user's timezone from MongoDB aggregation
+ if self.bucket_config:
+ display_value = format_time_bucket_display(
+ bucket_value, self.bucket_config
+ )
+ else:
+ display_value = bucket_value
+
+ formatted_results.append(
+ {
+ "date": display_value,
+ "total_clicks": result.get("total_clicks", 0),
+ "unique_clicks": result.get("unique_clicks", 0),
+ "bucket_strategy": self.bucket_config.strategy.value
+ if self.bucket_config
+ else "legacy",
+ "raw_bucket": bucket_value, # Include raw value for debugging
+ }
+ )
+
+ # Fill missing buckets if using dynamic bucketing and we have date range
+ # NOTE: We need to convert start/end dates to user timezone for proper bucket filling
+ if self.bucket_config and self.start_date and self.end_date:
+ # Convert date range to user timezone for bucket generation
+ from zoneinfo import ZoneInfo
+
+ user_tz = ZoneInfo(self.timezone)
+ start_in_tz = self.start_date.astimezone(user_tz)
+ end_in_tz = self.end_date.astimezone(user_tz)
+
+ formatted_results = fill_missing_buckets(
+ formatted_results, start_in_tz, end_in_tz, self.bucket_config
+ )
+
+ return formatted_results
+
+ def _convert_bucket_to_timezone(self, bucket_str: str) -> str:
+ """Convert UTC bucket timestamp to user's timezone"""
+ if self.timezone == "UTC":
+ return bucket_str # No conversion needed
+
+ try:
+ user_tz = ZoneInfo(self.timezone)
+
+ # Parse the bucket string based on its format
+ if self.bucket_config:
+ strategy = self.bucket_config.strategy.value
+
+ if "minute" in strategy or "hourly" in strategy:
+ # Format: "2025-01-01 14:30" or "2025-01-01 14:00"
+ dt = datetime.strptime(bucket_str, "%Y-%m-%d %H:%M")
+ elif "daily" in strategy:
+ # Format: "2025-01-01"
+ dt = datetime.strptime(bucket_str, "%Y-%m-%d")
+ elif "weekly" in strategy:
+ # Format: "2025-W01" - keep as is for now
+ return bucket_str
+ elif "monthly" in strategy:
+ # Format: "2025-01"
+ dt = datetime.strptime(bucket_str, "%Y-%m")
+ else:
+ return bucket_str
+ else:
+ # Legacy mode - try common formats
+ try:
+ dt = datetime.strptime(bucket_str, "%Y-%m-%d %H:%M")
+ except ValueError:
+ try:
+ dt = datetime.strptime(bucket_str, "%Y-%m-%d")
+ except ValueError:
+ return bucket_str
+
+ # Treat parsed datetime as UTC and convert to user timezone
+ from datetime import timezone as dt_timezone
+
+ dt_utc = dt.replace(tzinfo=dt_timezone.utc)
+ dt_user = dt_utc.astimezone(user_tz)
+
+ # Format back to string in the same format
+ if self.bucket_config:
+ strategy = self.bucket_config.strategy.value
+ if "minute" in strategy or "hourly" in strategy:
+ return dt_user.strftime("%Y-%m-%d %H:%M")
+ elif "daily" in strategy:
+ return dt_user.strftime("%Y-%m-%d")
+ elif "monthly" in strategy:
+ return dt_user.strftime("%Y-%m")
+
+ return dt_user.strftime("%Y-%m-%d %H:%M")
+
+ except Exception as e:
+ log.error(
+ "time_bucket_timezone_conversion_failed",
+ bucket=bucket_str,
+ timezone=self.timezone,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return bucket_str # Return original on error
+
+ @property
+ def dimension_name(self) -> str:
+ return "time"
+
+ def get_bucket_info(self) -> Dict[str, Any]:
+ """Get information about the current bucketing strategy"""
+ if self.bucket_config:
+ return {
+ "strategy": self.bucket_config.strategy.value,
+ "interval_minutes": self.bucket_config.interval_minutes,
+ "mongo_format": self.bucket_config.mongo_format,
+ "display_format": self.bucket_config.display_format,
+ "timezone": self.timezone,
+ }
+ else:
+ return {
+ "strategy": "legacy",
+ "mongo_format": self.time_format,
+ "display_format": self.time_format,
+ "timezone": self.timezone,
+ }
+
+
+class BrowserAggregationStrategy(AggregationStrategy):
+ """Strategy for browser-based aggregation"""
+
+ def build_pipeline(self, base_query: Dict[str, Any]) -> List[Dict[str, Any]]:
+ return [
+ {"$match": base_query},
+ {
+ "$group": {
+ "_id": {"$ifNull": ["$browser", "Unknown"]},
+ "total_clicks": {"$sum": 1},
+ "unique_clicks": {"$addToSet": "$ip_address"},
+ }
+ },
+ {"$addFields": {"unique_clicks": {"$size": "$unique_clicks"}}},
+ {"$sort": {"total_clicks": -1}},
+ ]
+
+ def format_results(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ return [
+ {
+ "browser": result["_id"],
+ "total_clicks": result.get("total_clicks", 0),
+ "unique_clicks": result.get("unique_clicks", 0),
+ }
+ for result in results
+ ]
+
+ @property
+ def dimension_name(self) -> str:
+ return "browser"
+
+
+class OSAggregationStrategy(AggregationStrategy):
+ """Strategy for operating system aggregation"""
+
+ def build_pipeline(self, base_query: Dict[str, Any]) -> List[Dict[str, Any]]:
+ return [
+ {"$match": base_query},
+ {
+ "$group": {
+ "_id": {"$ifNull": ["$os", "Unknown"]},
+ "total_clicks": {"$sum": 1},
+ "unique_clicks": {"$addToSet": "$ip_address"},
+ }
+ },
+ {"$addFields": {"unique_clicks": {"$size": "$unique_clicks"}}},
+ {"$sort": {"total_clicks": -1}},
+ ]
+
+ def format_results(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ return [
+ {
+ "os": result["_id"],
+ "total_clicks": result.get("total_clicks", 0),
+ "unique_clicks": result.get("unique_clicks", 0),
+ }
+ for result in results
+ ]
+
+ @property
+ def dimension_name(self) -> str:
+ return "os"
+
+
+class DeviceAggregationStrategy(AggregationStrategy):
+ """Strategy for device type aggregation"""
+
+ def build_pipeline(self, base_query: Dict[str, Any]) -> List[Dict[str, Any]]:
+ return [
+ {"$match": base_query},
+ {
+ "$group": {
+ "_id": {"$ifNull": ["$device", "Unknown"]},
+ "total_clicks": {"$sum": 1},
+ "unique_clicks": {"$addToSet": "$ip_address"},
+ }
+ },
+ {"$addFields": {"unique_clicks": {"$size": "$unique_clicks"}}},
+ {"$sort": {"total_clicks": -1}},
+ ]
+
+ def format_results(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ return [
+ {
+ "device": result["_id"],
+ "total_clicks": result.get("total_clicks", 0),
+ "unique_clicks": result.get("unique_clicks", 0),
+ }
+ for result in results
+ ]
+
+ @property
+ def dimension_name(self) -> str:
+ return "device"
+
+
+class CountryAggregationStrategy(AggregationStrategy):
+ """Strategy for country-based aggregation"""
+
+ def build_pipeline(self, base_query: Dict[str, Any]) -> List[Dict[str, Any]]:
+ return [
+ {"$match": base_query},
+ {
+ "$group": {
+ "_id": {"$ifNull": ["$country", "Unknown"]},
+ "total_clicks": {"$sum": 1},
+ "unique_clicks": {"$addToSet": "$ip_address"},
+ }
+ },
+ {"$addFields": {"unique_clicks": {"$size": "$unique_clicks"}}},
+ {"$sort": {"total_clicks": -1}},
+ ]
+
+ def format_results(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ return [
+ {
+ "country": convert_country_name(
+ result["_id"]
+ ), # Send country code after conversion
+ "total_clicks": result.get("total_clicks", 0),
+ "unique_clicks": result.get("unique_clicks", 0),
+ }
+ for result in results
+ ]
+
+ @property
+ def dimension_name(self) -> str:
+ return "country"
+
+
+class CityAggregationStrategy(AggregationStrategy):
+ """Strategy for city-based aggregation"""
+
+ def build_pipeline(self, base_query: Dict[str, Any]) -> List[Dict[str, Any]]:
+ return [
+ {"$match": base_query},
+ {
+ "$group": {
+ "_id": {"$ifNull": ["$city", "Unknown"]},
+ "total_clicks": {"$sum": 1},
+ "unique_clicks": {"$addToSet": "$ip_address"},
+ }
+ },
+ {"$addFields": {"unique_clicks": {"$size": "$unique_clicks"}}},
+ {"$sort": {"total_clicks": -1}},
+ ]
+
+ def format_results(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ return [
+ {
+ "city": result["_id"],
+ "total_clicks": result.get("total_clicks", 0),
+ "unique_clicks": result.get("unique_clicks", 0),
+ }
+ for result in results
+ ]
+
+ @property
+ def dimension_name(self) -> str:
+ return "city"
+
+
+class ReferrerAggregationStrategy(AggregationStrategy):
+ """Strategy for referrer-based aggregation"""
+
+ def build_pipeline(self, base_query: Dict[str, Any]) -> List[Dict[str, Any]]:
+ return [
+ {"$match": base_query},
+ {
+ "$group": {
+ "_id": {"$ifNull": ["$referrer", "Direct"]},
+ "total_clicks": {"$sum": 1},
+ "unique_clicks": {"$addToSet": "$ip_address"},
+ }
+ },
+ {"$addFields": {"unique_clicks": {"$size": "$unique_clicks"}}},
+ {"$sort": {"total_clicks": -1}},
+ ]
+
+ def format_results(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ return [
+ {
+ "referrer": result["_id"],
+ "total_clicks": result.get("total_clicks", 0),
+ "unique_clicks": result.get("unique_clicks", 0),
+ }
+ for result in results
+ ]
+
+ @property
+ def dimension_name(self) -> str:
+ return "referrer"
+
+
+class ShortCodeAggregationStrategy(AggregationStrategy):
+ """Strategy for short_code-based aggregation (grouping by short codes/aliases)"""
+
+ def build_pipeline(self, base_query: Dict[str, Any]) -> List[Dict[str, Any]]:
+ return [
+ {"$match": base_query},
+ {
+ "$group": {
+ "_id": {"$ifNull": ["$meta.short_code", "Unknown"]},
+ "total_clicks": {"$sum": 1},
+ "unique_clicks": {"$addToSet": "$ip_address"},
+ }
+ },
+ {"$addFields": {"unique_clicks": {"$size": "$unique_clicks"}}},
+ {"$sort": {"total_clicks": -1}},
+ ]
+
+ def format_results(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ return [
+ {
+ "short_code": result["_id"],
+ "total_clicks": result.get("total_clicks", 0),
+ "unique_clicks": result.get("unique_clicks", 0),
+ }
+ for result in results
+ ]
+
+ @property
+ def dimension_name(self) -> str:
+ return "short_code"
+
+
+class AggregationStrategyFactory:
+ """Factory for creating aggregation strategies"""
+
+ _strategies = {
+ "time": TimeAggregationStrategy,
+ "browser": BrowserAggregationStrategy,
+ "os": OSAggregationStrategy,
+ "device": DeviceAggregationStrategy,
+ "country": CountryAggregationStrategy,
+ "city": CityAggregationStrategy,
+ "referrer": ReferrerAggregationStrategy,
+ "short_code": ShortCodeAggregationStrategy,
+ }
+
+ @classmethod
+ def get(cls, strategy_name: str, **kwargs) -> AggregationStrategy:
+ """Get an aggregation strategy by name"""
+ if strategy_name not in cls._strategies:
+ raise ValueError(f"Unknown aggregation strategy: {strategy_name}")
+
+ strategy_class = cls._strategies[strategy_name]
+ return strategy_class(**kwargs)
+
+ @classmethod
+ def get_available_strategies(cls) -> List[str]:
+ """Get list of available strategy names"""
+ return list(cls._strategies.keys())
diff --git a/utils/analytics_utils.py b/utils/analytics_utils.py
index d43c1bfe..5e8bdef6 100644
--- a/utils/analytics_utils.py
+++ b/utils/analytics_utils.py
@@ -9,13 +9,25 @@ def convert_country_data(data):
@functools.lru_cache(maxsize=None)
def convert_country_name(country_name: str) -> str:
+ """
+ Convert country name to ISO 2-letter country code with caching
+
+ Args:
+ country_name: Full country name (e.g., "United States", "Germany")
+
+ Returns:
+ ISO 2-letter country code (e.g., "US", "DE") or "XX" if not found
+ """
try:
return pycountry.countries.lookup(country_name.strip()).alpha_2
- except LookupError:
+ except (LookupError, ImportError):
+ # Handle special cases and fallback
if country_name == "Turkey":
return "TR"
elif country_name == "Russia":
return "RU"
+ elif country_name == "Unknown":
+ return "XX"
return "XX"
diff --git a/utils/auth_utils.py b/utils/auth_utils.py
new file mode 100644
index 00000000..1d9e951d
--- /dev/null
+++ b/utils/auth_utils.py
@@ -0,0 +1,445 @@
+import os
+import hashlib
+from datetime import datetime, timedelta, timezone
+from functools import wraps, lru_cache
+from typing import Any, Dict
+
+from flask import request, jsonify, g, make_response
+import jwt
+from argon2 import PasswordHasher
+from bson import ObjectId
+from utils.mongo_utils import find_api_key_by_hash
+from utils.logger import get_logger
+
+
+password_hasher = PasswordHasher()
+log = get_logger(__name__)
+
+
+@lru_cache(maxsize=1)
+def _use_rs256() -> bool:
+ return bool(os.getenv("JWT_PRIVATE_KEY") and os.getenv("JWT_PUBLIC_KEY"))
+
+
+@lru_cache(maxsize=1)
+def _jwt_keys():
+ if _use_rs256():
+ priv = os.getenv("JWT_PRIVATE_KEY") or ""
+ pub = os.getenv("JWT_PUBLIC_KEY") or ""
+ # Support keys provided via env with literal \n sequences
+ priv = priv.replace("\\n", "\n").encode("utf-8")
+ pub = pub.replace("\\n", "\n").encode("utf-8")
+ return (priv, pub)
+ else:
+ secret = os.getenv("JWT_SECRET")
+ if not secret:
+ raise RuntimeError(
+ "JWT_SECRET must be set when RS256 keys are not provided"
+ )
+ return (secret, secret)
+
+
+@lru_cache(maxsize=1)
+def _jwt_settings():
+ issuer = os.getenv("JWT_ISSUER", "spoo.me")
+ audience = os.getenv("JWT_AUDIENCE", "spoo.me.api")
+ access_ttl = int(os.getenv("ACCESS_TOKEN_TTL_SECONDS", "900"))
+ refresh_ttl = int(os.getenv("REFRESH_TOKEN_TTL_SECONDS", "2592000"))
+ return issuer, audience, access_ttl, refresh_ttl
+
+
+def hash_password(plain_password: str) -> str:
+ return password_hasher.hash(plain_password)
+
+
+def verify_password(plain_password: str, password_hash: str) -> bool:
+ try:
+ password_hasher.verify(password_hash, plain_password)
+ return True
+ except Exception:
+ return False
+
+
+def generate_access_jwt(
+ user_id: str, email_verified: bool = False, auth_method: str = "pwd"
+) -> str:
+ issuer, audience, access_ttl, _ = _jwt_settings()
+ private_key, _ = _jwt_keys()
+ algorithm = "RS256" if _use_rs256() else "HS256"
+ now = datetime.now(timezone.utc)
+ claims = {
+ "iss": issuer,
+ "aud": audience,
+ "sub": str(user_id),
+ "iat": int(now.timestamp()),
+ "exp": int((now + timedelta(seconds=access_ttl)).timestamp()),
+ "amr": [auth_method], # Authentication Methods References
+ "email_verified": email_verified, # Email verification status
+ }
+ return jwt.encode(claims, private_key, algorithm=algorithm)
+
+
+def verify_access_jwt(token: str):
+ issuer, audience, *_ = _jwt_settings()
+ _, public_key = _jwt_keys()
+ algorithm = "RS256" if _use_rs256() else "HS256"
+ return jwt.decode(
+ token, public_key, algorithms=[algorithm], audience=audience, issuer=issuer
+ )
+
+
+def generate_refresh_jwt(
+ user_id: str, email_verified: bool = False, auth_method: str = "pwd"
+) -> str:
+ """Generate a stateless refresh JWT token."""
+ issuer, audience, _, refresh_ttl = _jwt_settings()
+ private_key, _ = _jwt_keys()
+ algorithm = "RS256" if _use_rs256() else "HS256"
+ now = datetime.now(timezone.utc)
+ claims = {
+ "iss": issuer,
+ "aud": audience,
+ "sub": str(user_id),
+ "iat": int(now.timestamp()),
+ "exp": int((now + timedelta(seconds=refresh_ttl)).timestamp()),
+ "type": "refresh",
+ "amr": [auth_method], # Authentication Methods References
+ "email_verified": email_verified, # Email verification status
+ }
+ return jwt.encode(claims, private_key, algorithm=algorithm)
+
+
+def verify_refresh_jwt(token: str):
+ """Verify and decode a refresh JWT token."""
+ issuer, audience, *_ = _jwt_settings()
+ _, public_key = _jwt_keys()
+ algorithm = "RS256" if _use_rs256() else "HS256"
+ claims = jwt.decode(
+ token, public_key, algorithms=[algorithm], audience=audience, issuer=issuer
+ )
+ # Ensure it's a refresh token
+ if claims.get("type") != "refresh":
+ raise jwt.InvalidTokenError("Not a refresh token")
+ return claims
+
+
+def set_refresh_cookie(response, token: str):
+ secure = os.getenv("COOKIE_SECURE", "true").lower() == "true"
+ *_, refresh_ttl = _jwt_settings()
+ response.set_cookie(
+ "refresh_token",
+ value=token,
+ httponly=True,
+ secure=secure,
+ samesite="Lax",
+ path="/",
+ max_age=refresh_ttl,
+ )
+ return response
+
+
+def clear_refresh_cookie(response):
+ secure = os.getenv("COOKIE_SECURE", "true").lower() == "true"
+ response.set_cookie(
+ "refresh_token",
+ value="",
+ expires=0,
+ httponly=True,
+ secure=secure,
+ samesite="Lax",
+ path="/",
+ )
+ return response
+
+
+def set_access_cookie(response, token: str):
+ secure = os.getenv("COOKIE_SECURE", "true").lower() == "true"
+ issuer, audience, access_ttl, _ = _jwt_settings()
+ response.set_cookie(
+ "access_token",
+ value=token,
+ httponly=True,
+ secure=secure,
+ samesite="Lax",
+ path="/",
+ max_age=access_ttl,
+ )
+ return response
+
+
+def clear_access_cookie(response):
+ secure = os.getenv("COOKIE_SECURE", "true").lower() == "true"
+ response.set_cookie(
+ "access_token",
+ value="",
+ expires=0,
+ httponly=True,
+ secure=secure,
+ samesite="Lax",
+ path="/",
+ )
+ return response
+
+
+def requires_auth(fn):
+ @wraps(fn)
+ def wrapper(*args, **kwargs):
+ auth_header = request.headers.get("Authorization", "")
+ token = None
+ if auth_header.lower().startswith("bearer "):
+ token = auth_header.split(" ", 1)[1].strip()
+ if not token:
+ token = request.cookies.get("access_token")
+
+ if not token:
+ # Attempt refresh when access token is missing but refresh token exists
+ refresh_token = request.cookies.get("refresh_token")
+ if refresh_token:
+ try:
+ refresh_claims = verify_refresh_jwt(refresh_token)
+ user_id = refresh_claims.get("sub")
+
+ from utils.mongo_utils import get_user_by_id
+
+ # Fetch fresh email_verified status from DB
+ user = get_user_by_id(user_id, projection={"email_verified": 1})
+ email_verified = (
+ user.get("email_verified", False) if user else False
+ )
+
+ new_access_token = generate_access_jwt(user_id, email_verified)
+ new_refresh_token = generate_refresh_jwt(user_id, email_verified)
+ g.user_id = user_id
+ g.jwt_claims = {"email_verified": email_verified}
+ resp = fn(*args, **kwargs)
+ resp = make_response(resp)
+ set_refresh_cookie(resp, new_refresh_token)
+ set_access_cookie(resp, new_access_token)
+ return resp
+ except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
+ pass
+ except Exception:
+ pass
+ return _handle_auth_failure("missing access token")
+
+ try:
+ claims = verify_access_jwt(token)
+ g.user_id = claims.get("sub")
+ g.jwt_claims = claims
+ resp = fn(*args, **kwargs)
+ return resp
+ except jwt.ExpiredSignatureError:
+ # Attempt refresh using stateless refresh JWT
+ refresh_token = request.cookies.get("refresh_token")
+ if not refresh_token:
+ return _handle_auth_failure("invalid or expired token")
+
+ try:
+ # Verify refresh token (stateless)
+ refresh_claims = verify_refresh_jwt(refresh_token)
+ user_id = refresh_claims.get("sub")
+
+ # Fetch fresh email_verified status from DB
+ from utils.mongo_utils import get_user_by_id
+
+ user = get_user_by_id(user_id, projection={"email_verified": 1})
+ email_verified = user.get("email_verified", False) if user else False
+
+ # Generate new tokens (token rotation) with fresh verification status
+ new_access_token = generate_access_jwt(user_id, email_verified)
+ new_refresh_token = generate_refresh_jwt(user_id, email_verified)
+
+ # Set user context for the request
+ g.user_id = user_id
+ g.jwt_claims = {"email_verified": email_verified}
+
+ # Call the view and attach new cookies to response
+ resp = fn(*args, **kwargs)
+ resp = make_response(resp)
+ set_refresh_cookie(resp, new_refresh_token)
+ set_access_cookie(resp, new_access_token)
+ return resp
+
+ except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
+ return _handle_auth_failure("invalid or expired token")
+ except Exception:
+ return _handle_auth_failure("invalid or expired token")
+ except Exception:
+ return _handle_auth_failure("invalid or expired token")
+
+ return wrapper
+
+
+def _handle_auth_failure(error_msg: str):
+ """Handle authentication failures: JSON for APIs, 401 HTML page for browser routes."""
+ accept_header = request.headers.get("Accept", "")
+ wants_json = (
+ request.is_json
+ or "application/json" in accept_header
+ or request.path.startswith("/auth/")
+ )
+ if wants_json:
+ return jsonify({"error": error_msg}), 401
+ else:
+ from flask import render_template
+
+ return (
+ render_template(
+ "error.html",
+ error_code="401",
+ error_message=error_msg.upper(),
+ host_url=request.host_url,
+ ),
+ 401,
+ )
+
+
+def resolve_owner_id_from_request(require_verified: bool = False):
+ """Resolve the authenticated user id from either API key or JWT.
+
+ Args:
+ require_verified: If True, sets g.verification_error for unverified users
+ and returns None (treated as anonymous)
+
+ - API key: Authorization: Bearer spoo_
+ - Validates revocation/expiry and sets g.api_key and request.api_key
+ - Returns ObjectId of user
+ - JWT: Authorization: Bearer or access_token cookie
+ - Returns ObjectId of user (if require_verified=True, checks email_verified)
+ - Otherwise returns None
+ """
+ auth_header = request.headers.get("Authorization", "")
+ token = None
+ if auth_header.lower().startswith("bearer "):
+ token = auth_header.split(" ", 1)[1].strip()
+ # API key path: Authorization: Bearer spoo_
+ if token.startswith("spoo_"):
+ raw = token[len("spoo_") :]
+ token_hash = hashlib.sha256(raw.encode("utf-8")).hexdigest()
+ key_doc = find_api_key_by_hash(token_hash)
+ now = datetime.now(timezone.utc)
+
+ # Check if key exists
+ if not key_doc:
+ log.warning(
+ "api_key_invalid",
+ key_prefix=raw[:8] if len(raw) >= 8 else "short",
+ reason="not_found",
+ )
+ return None
+
+ # Check if key is revoked
+ if key_doc.get("revoked", False):
+ log.warning(
+ "api_key_invalid",
+ key_prefix=key_doc.get("token_prefix", "unknown"),
+ key_id=str(key_doc.get("_id")),
+ user_id=str(key_doc.get("user_id")),
+ reason="revoked",
+ )
+ return None
+
+ # Check if key is expired
+ if key_doc.get("expires_at") and key_doc["expires_at"] <= now:
+ log.warning(
+ "api_key_invalid",
+ key_prefix=key_doc.get("token_prefix", "unknown"),
+ key_id=str(key_doc.get("_id")),
+ user_id=str(key_doc.get("user_id")),
+ reason="expired",
+ expired_at=key_doc["expires_at"].isoformat(),
+ )
+ return None
+
+ if (
+ key_doc
+ and not key_doc.get("revoked", False)
+ and (not key_doc.get("expires_at") or key_doc["expires_at"] > now)
+ ):
+ # Attach scopes for downstream checks
+ try:
+ g.api_key = key_doc # type: ignore[attr-defined]
+ except Exception:
+ pass
+ try:
+ request.api_key = key_doc # type: ignore[attr-defined]
+ except Exception:
+ pass
+ user_id = key_doc.get("user_id")
+ try:
+ return (
+ ObjectId(user_id)
+ if not isinstance(user_id, ObjectId)
+ else user_id
+ )
+ except Exception:
+ return None
+ if not token:
+ token = request.cookies.get("access_token")
+ if token:
+ try:
+ claims = verify_access_jwt(token)
+ user_id = claims.get("sub")
+
+ # Check email verification if required
+ if require_verified and not claims.get("email_verified", False):
+ log.warning(
+ "resource_creation_blocked",
+ reason="email_not_verified",
+ user_id=user_id,
+ )
+ # Store error in g so builder can access it
+ try:
+ g.verification_error = {
+ "error": "Email verification required",
+ "code": "EMAIL_NOT_VERIFIED",
+ "message": "You must verify your email address before creating resources. Check your inbox for the verification code.",
+ }
+ except Exception:
+ pass
+ return None
+
+ return ObjectId(user_id)
+ except Exception:
+ pass
+ return None
+
+
+def get_user_profile(user_doc: Dict[str, Any]) -> Dict[str, Any]:
+ """Create minimal user profile including OAuth provider info
+
+ Args:
+ user_doc: User document from database
+
+ Returns:
+ Minimal user profile dict
+ """
+ profile = {
+ "id": str(user_doc["_id"]),
+ "email": user_doc.get("email"),
+ "email_verified": user_doc.get("email_verified", False),
+ "user_name": user_doc.get("user_name"),
+ "plan": user_doc.get("plan", "free"),
+ "password_set": user_doc.get("password_set", False),
+ "auth_providers": [],
+ }
+
+ # Add OAuth providers info (without sensitive data)
+ auth_providers = user_doc.get("auth_providers", [])
+ for provider in auth_providers:
+ profile["auth_providers"].append(
+ {
+ "provider": provider.get("provider"),
+ "email": provider.get("email"),
+ "linked_at": provider.get("linked_at").isoformat()
+ if provider.get("linked_at")
+ else None,
+ }
+ )
+
+ # Add profile picture info
+ pfp = user_doc.get("pfp")
+ if pfp:
+ profile["pfp"] = {"url": pfp.get("url"), "source": pfp.get("source")}
+
+ return profile
diff --git a/utils/contact_utils.py b/utils/contact_utils.py
index 6b03b483..ebc30dd6 100644
--- a/utils/contact_utils.py
+++ b/utils/contact_utils.py
@@ -2,9 +2,12 @@
from datetime import datetime, timezone
import os
from dotenv import load_dotenv
+from utils.logger import get_logger
load_dotenv()
+log = get_logger(__name__)
+
CONTACT_WEBHOOK = os.environ["CONTACT_WEBHOOK"]
URL_REPORT_WEBHOOK = os.environ["URL_REPORT_WEBHOOK"]
hcaptcha_secret = os.environ.get("HCAPTCHA_SECRET")
@@ -13,18 +16,34 @@
def verify_hcaptcha(token):
hcaptcha_verify_url = "https://hcaptcha.com/siteverify"
- response = requests.post(
- hcaptcha_verify_url,
- data={
- "response": token,
- "secret": hcaptcha_secret,
- },
- )
+ try:
+ response = requests.post(
+ hcaptcha_verify_url,
+ data={
+ "response": token,
+ "secret": hcaptcha_secret,
+ },
+ timeout=5,
+ )
- if response.status_code == 200:
- data = response.json()
- return data["success"]
- else:
+ if response.status_code == 200:
+ data = response.json()
+ success = data.get("success", False)
+ if not success:
+ log.warning(
+ "hcaptcha_verification_failed",
+ error_codes=data.get("error-codes", []),
+ )
+ return success
+ else:
+ log.error(
+ "hcaptcha_api_error",
+ status_code=response.status_code,
+ response_text=response.text[:200],
+ )
+ return False
+ except requests.exceptions.RequestException as e:
+ log.error("hcaptcha_request_failed", error=str(e), error_type=type(e).__name__)
return False
@@ -49,7 +68,22 @@ def send_report(webhook_uri, short_code, reason, ip_address, host_uri):
]
}
- requests.post(webhook_uri, json=data)
+ try:
+ response = requests.post(webhook_uri, json=data, timeout=5)
+ if response.status_code not in (200, 204):
+ log.warning(
+ "report_webhook_failed",
+ short_code=short_code,
+ status_code=response.status_code,
+ response_text=response.text[:200],
+ )
+ except requests.exceptions.RequestException as e:
+ log.error(
+ "report_webhook_request_failed",
+ short_code=short_code,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
def send_contact_message(webhook_uri, email, message):
@@ -71,4 +105,19 @@ def send_contact_message(webhook_uri, email, message):
]
}
- requests.post(webhook_uri, json=data)
+ try:
+ response = requests.post(webhook_uri, json=data, timeout=5)
+ if response.status_code not in (200, 204):
+ log.warning(
+ "contact_webhook_failed",
+ email=email,
+ status_code=response.status_code,
+ response_text=response.text[:200],
+ )
+ except requests.exceptions.RequestException as e:
+ log.error(
+ "contact_webhook_request_failed",
+ email=email,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
diff --git a/utils/email_service.py b/utils/email_service.py
new file mode 100644
index 00000000..be175c30
--- /dev/null
+++ b/utils/email_service.py
@@ -0,0 +1,279 @@
+"""
+Email service using ZeptoMail for transactional emails
+"""
+
+import os
+import requests
+from typing import Optional
+from jinja2 import Environment, FileSystemLoader, select_autoescape
+
+from utils.logger import get_logger
+
+log = get_logger(__name__)
+
+# Initialize Jinja2 environment for email templates
+TEMPLATE_DIR = os.path.join(
+ os.path.dirname(os.path.dirname(__file__)), "templates", "emails"
+)
+jinja_env = Environment(
+ loader=FileSystemLoader(TEMPLATE_DIR), autoescape=select_autoescape(["html", "xml"])
+)
+
+# ZeptoMail Configuration
+ZEPTO_API_URL = "https://api.zeptomail.in/v1.1/email"
+ZEPTO_API_TOKEN = os.getenv("ZEPTO_API_TOKEN", "")
+ZEPTO_FROM_EMAIL = os.getenv("ZEPTO_FROM_EMAIL", "noreply@spoo.me")
+ZEPTO_FROM_NAME = os.getenv("ZEPTO_FROM_NAME", "spoo.me")
+APP_NAME = "spoo.me"
+APP_URL = os.getenv("APP_URL", "https://spoo.me")
+
+
+class ZeptoMailService:
+ """Service for sending transactional emails via ZeptoMail API"""
+
+ def __init__(self):
+ self.api_url = ZEPTO_API_URL
+ self.api_token = ZEPTO_API_TOKEN
+ self.from_email = ZEPTO_FROM_EMAIL
+ self.from_name = ZEPTO_FROM_NAME
+
+ if not self.api_token:
+ log.error(
+ "zepto_mail_token_missing", message="ZEPTO_API_TOKEN not configured"
+ )
+
+ def _send_email(
+ self,
+ to_email: str,
+ to_name: Optional[str],
+ subject: str,
+ html_body: str,
+ text_body: Optional[str] = None,
+ ) -> bool:
+ """
+ Send an email via ZeptoMail API
+
+ Args:
+ to_email: Recipient email address
+ to_name: Recipient name
+ subject: Email subject
+ html_body: HTML email body
+ text_body: Plain text email body (optional)
+
+ Returns:
+ True if successful, False otherwise
+ """
+ if not self.api_token:
+ log.error("zepto_mail_send_failed", reason="token_not_configured")
+ return False
+
+ try:
+ # Prepare request payload
+ payload = {
+ "from": {
+ "address": self.from_email,
+ "name": self.from_name,
+ },
+ "to": [
+ {
+ "email_address": {
+ "address": to_email,
+ "name": to_name or to_email,
+ }
+ }
+ ],
+ "subject": subject,
+ "htmlbody": html_body,
+ }
+
+ if text_body:
+ payload["textbody"] = text_body
+
+ # Prepare headers
+ # Check if token already has the prefix
+ auth_token = self.api_token
+ if not auth_token.startswith("Zoho-enczapikey "):
+ auth_token = f"Zoho-enczapikey {auth_token}"
+
+ headers = {
+ "Authorization": auth_token,
+ "Content-Type": "application/json",
+ }
+
+ # Send request
+ response = requests.post(
+ self.api_url,
+ json=payload,
+ headers=headers,
+ timeout=10,
+ )
+
+ if response.status_code in [200, 201, 202]:
+ log.info(
+ "email_sent_success",
+ to_email=to_email,
+ subject=subject,
+ status_code=response.status_code,
+ )
+ return True
+ else:
+ log.error(
+ "email_sent_failed",
+ to_email=to_email,
+ subject=subject,
+ status_code=response.status_code,
+ response=response.text,
+ )
+ return False
+
+ except requests.exceptions.Timeout:
+ log.error("email_send_timeout", to_email=to_email, subject=subject)
+ return False
+ except Exception as e:
+ log.error(
+ "email_send_error",
+ to_email=to_email,
+ subject=subject,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return False
+
+ def send_verification_email(
+ self, email: str, user_name: Optional[str], otp_code: str
+ ) -> bool:
+ """
+ Send email verification OTP
+
+ Args:
+ email: Recipient email
+ user_name: User's name
+ otp_code: 6-digit OTP code
+
+ Returns:
+ True if sent successfully
+ """
+ subject = f"Verify your email - {APP_NAME}"
+
+ # Render template
+ template = jinja_env.get_template("verification.html")
+ html_body = template.render(
+ otp_code=otp_code, user_name=user_name, app_url=APP_URL
+ )
+
+ text_body = f"""
+Verify Your Email - spoo.me
+
+Hello{f" {user_name}" if user_name else ""},
+
+We received a request to verify your email address for your spoo.me account. Please use the verification code below to complete the process:
+
+{otp_code}
+
+Enter this code in the verification field to complete your account setup. This code will expire in 10 minutes for security purposes.
+
+If you didn't request this verification, please ignore this email or contact our support team if you have concerns.
+
+Need help? Contact us at support@spoo.me
+
+© 2025 spoo.me. All rights reserved.
+ """
+
+ return self._send_email(email, user_name, subject, html_body, text_body)
+
+ def send_password_reset_email(
+ self, email: str, user_name: Optional[str], otp_code: str
+ ) -> bool:
+ """
+ Send password reset OTP
+
+ Args:
+ email: Recipient email
+ user_name: User's name
+ otp_code: 6-digit OTP code
+
+ Returns:
+ True if sent successfully
+ """
+ subject = "Reset your password - spoo.me"
+
+ # Render template
+ template = jinja_env.get_template("password_reset.html")
+ html_body = template.render(
+ otp_code=otp_code, user_name=user_name, app_url=APP_URL
+ )
+
+ text_body = f"""
+Reset Your Password - spoo.me
+
+Hello{f" {user_name}" if user_name else ""},
+
+We received a request to reset your password for your spoo.me account. Please use the verification code below to proceed:
+
+{otp_code}
+
+Enter this code to reset your password. This code will expire in 10 minutes for security purposes.
+
+⚠️ SECURITY NOTICE: If you didn't request a password reset, please ignore this email and consider changing your password immediately.
+
+Need help? Contact us at support@spoo.me
+
+© 2025 spoo.me. All rights reserved.
+ """
+
+ return self._send_email(email, user_name, subject, html_body, text_body)
+
+ def send_welcome_email(self, email: str, user_name: Optional[str]) -> bool:
+ """
+ Send welcome email after successful verification
+
+ Args:
+ email: Recipient email
+ user_name: User's name
+
+ Returns:
+ True if sent successfully
+ """
+ subject = "Welcome to spoo.me! 🎉"
+
+ # Render template
+ template = jinja_env.get_template("welcome.html")
+ html_body = template.render(user_name=user_name, app_url=APP_URL)
+
+ text_body = f"""
+Welcome to spoo.me{f", {user_name}" if user_name else ""}! 🎉
+
+Thank you for joining the modern URL shortener built for developers, marketers, and businesses who demand more from their links.
+
+What makes spoo.me different?
+
+🚀 Powerful Analytics
+Track clicks, locations, devices, and referrers with detailed insights that help you understand your audience better.
+
+⚡ Developer-Friendly API
+Integrate seamlessly with our RESTful API and comprehensive documentation designed for modern development workflows.
+
+🔒 Enterprise Security
+Password protection, and expiration dates give you complete control over your links.
+
+🔄 Link Management
+Edit, pause, or delete links anytime with full control over your URLs.
+
+Ready to get started?
+1. Visit your dashboard to create your first short link
+2. Explore our analytics to understand your audience
+3. Check out our API documentation for advanced integrations
+
+Get started: {APP_URL}/dashboard
+
+Need help getting started? We're here to support you every step of the way.
+Contact us at support@spoo.me
+
+© 2025 spoo.me. All rights reserved.
+ """
+
+ return self._send_email(email, user_name, subject, html_body, text_body)
+
+
+# Global instance
+email_service = ZeptoMailService()
diff --git a/utils/log_context.py b/utils/log_context.py
new file mode 100644
index 00000000..0428c2f6
--- /dev/null
+++ b/utils/log_context.py
@@ -0,0 +1,178 @@
+"""
+Flask middleware for automatic request logging and context management.
+
+Provides:
+- Automatic request ID generation for correlation
+- Request/response logging with timing
+- User context binding (user_id, ip, etc.)
+- Context available throughout request lifecycle
+"""
+
+import time
+import uuid
+from typing import Optional
+
+import structlog
+from flask import Flask, request, g
+from werkzeug.exceptions import HTTPException
+
+from .logger import get_logger, hash_ip
+
+
+def generate_request_id() -> str:
+ """Generate a unique request ID for correlation."""
+ return f"req_{uuid.uuid4().hex[:12]}"
+
+
+def get_user_context() -> dict:
+ """
+ Extract user context from Flask g object.
+
+ Returns:
+ Dictionary with user_id and auth_method if available
+ """
+ context = {}
+
+ # Get user_id from g object (set by @requires_auth decorator)
+ if hasattr(g, "user_id") and g.user_id:
+ context["user_id"] = str(g.user_id)
+
+ # Get auth method from JWT claims
+ if hasattr(g, "jwt_claims") and g.jwt_claims:
+ amr = g.jwt_claims.get("amr", [])
+ if amr:
+ context["auth_method"] = amr[0] if isinstance(amr, list) else amr
+
+ # Check if request is using API key
+ if hasattr(g, "api_key") and g.api_key:
+ context["auth_method"] = "api_key"
+ context["api_key_prefix"] = g.api_key.get("token_prefix")
+
+ return context
+
+
+def log_request_start(log: structlog.stdlib.BoundLogger) -> None:
+ """Log the start of a request with context."""
+ # Only log request start for non-redirect endpoints in production
+ # (to reduce noise from high-frequency redirects)
+ from .logging_config import IS_PRODUCTION
+
+ if IS_PRODUCTION and not request.path.startswith("/api/"):
+ return
+
+ log.debug(
+ "request_started",
+ method=request.method,
+ path=request.path,
+ query_string=request.query_string.decode() if request.query_string else None,
+ )
+
+
+def log_request_end(
+ log: structlog.stdlib.BoundLogger,
+ status_code: int,
+ duration_ms: int,
+) -> None:
+ """Log the end of a request with timing and status."""
+ # Determine log level based on status code
+ if status_code >= 500:
+ log_fn = log.error
+ elif status_code >= 400:
+ log_fn = log.warning
+ else:
+ log_fn = log.info
+
+ log_fn(
+ "request_completed",
+ method=request.method,
+ path=request.path,
+ status_code=status_code,
+ duration_ms=duration_ms,
+ )
+
+
+def setup_logging_middleware(app: Flask) -> None:
+ """
+ Register logging middleware with Flask app.
+
+ This sets up:
+ - Request ID generation
+ - User context binding
+ - Automatic request/response logging
+ - Context available via g.log throughout request
+
+ Args:
+ app: Flask application instance
+
+ Example:
+ >>> from flask import Flask
+ >>> from utils.log_context import setup_logging_middleware
+ >>> app = Flask(__name__)
+ >>> setup_logging_middleware(app)
+ """
+
+ @app.before_request
+ def before_request():
+ """Setup logging context before each request."""
+ # Record start time for duration calculation
+ g.request_start_time = time.time()
+
+ # Generate unique request ID
+ request_id = generate_request_id()
+ g.request_id = request_id
+
+ # Get client IP (handles proxy headers)
+ from utils.url_utils import get_client_ip
+
+ client_ip = get_client_ip()
+
+ # Create base logger with request context
+ log = get_logger("spoo.request")
+ log = log.bind(
+ request_id=request_id,
+ method=request.method,
+ path=request.path,
+ ip_hash=hash_ip(client_ip),
+ user_agent=request.headers.get("User-Agent", "")[:100], # Truncate
+ )
+
+ # Add user context if available
+ user_context = get_user_context()
+ if user_context:
+ log = log.bind(**user_context)
+
+ # Store logger in g for use throughout request
+ g.log = log
+
+ # Log request start
+ log_request_start(log)
+
+ @app.after_request
+ def after_request(response):
+ """Log request completion after response is ready."""
+ if not hasattr(g, "log") or not hasattr(g, "request_start_time"):
+ return response
+
+ # Calculate request duration
+ duration_ms = int((time.time() - g.request_start_time) * 1000)
+
+ # Log request completion
+ log_request_end(g.log, response.status_code, duration_ms)
+
+ # Add request ID to response headers for debugging
+ response.headers["X-Request-ID"] = g.request_id
+
+ return response
+
+ @app.teardown_request
+ def teardown_request(exc: Optional[Exception] = None):
+ """Handle any exceptions that occurred during request."""
+ if exc and not isinstance(exc, HTTPException):
+ # Log unhandled exceptions
+ if hasattr(g, "log"):
+ g.log.error(
+ "unhandled_exception",
+ error=str(exc),
+ error_type=type(exc).__name__,
+ exc_info=exc,
+ )
diff --git a/utils/logger.py b/utils/logger.py
new file mode 100644
index 00000000..12ec895c
--- /dev/null
+++ b/utils/logger.py
@@ -0,0 +1,118 @@
+"""
+Logger factory and utility functions for spoo.me URL shortener.
+
+Provides:
+- get_logger(): Get a configured logger instance
+- should_sample(): Determine if an event should be logged based on sampling rate
+- hash_ip(): Hash IP addresses for privacy
+"""
+
+import random
+from typing import Optional
+
+import structlog
+from structlog.stdlib import BoundLogger
+
+from .logging_config import SAMPLING_RATES, hash_ip as _hash_ip
+
+
+def get_logger(name: str) -> BoundLogger:
+ """
+ Get a configured logger instance.
+
+ Args:
+ name: Logger name (typically __name__ of the calling module)
+
+ Returns:
+ Configured structlog BoundLogger instance
+
+ Example:
+ >>> from utils.logger import get_logger
+ >>> log = get_logger(__name__)
+ >>> log.info("user_login", user_id="123", method="password")
+ """
+ return structlog.get_logger(name)
+
+
+def should_sample(event_type: str) -> bool:
+ """
+ Determine if an event should be logged based on sampling rate.
+
+ Uses random sampling to reduce log volume for high-frequency events.
+ Sampling rates are configured in logging_config.py and can be overridden
+ via environment variables.
+
+ Args:
+ event_type: Type of event (e.g., "url_redirect", "stats_query")
+
+ Returns:
+ True if the event should be logged, False otherwise
+
+ Example:
+ >>> from utils.logger import get_logger, should_sample
+ >>> log = get_logger(__name__)
+ >>> if should_sample("url_redirect"):
+ ... log.info("url_redirect", short_code="abc123")
+ """
+ # Get sampling rate for this event type (default to 100% if not configured)
+ sample_rate = SAMPLING_RATES.get(event_type, 1.0)
+
+ # Always log if rate is 1.0 (100%)
+ if sample_rate >= 1.0:
+ return True
+
+ # Never log if rate is 0.0 (0%)
+ if sample_rate <= 0.0:
+ return False
+
+ # Probabilistic sampling
+ return random.random() < sample_rate
+
+
+def hash_ip(ip_address: Optional[str]) -> Optional[str]:
+ """
+ Hash IP address for privacy in production.
+
+ This is a convenience wrapper around logging_config.hash_ip()
+ that handles None values gracefully.
+
+ In production: Returns SHA-256 hash (first 16 chars) for GDPR compliance
+ In development: Returns the original IP for easier debugging
+
+ Args:
+ ip_address: The IP address to hash (can be None)
+
+ Returns:
+ Hashed IP (production), original IP (development), or None
+
+ Example:
+ >>> from utils.logger import get_logger, hash_ip
+ >>> log = get_logger(__name__)
+ >>> log.warning("suspicious_activity", ip_hash=hash_ip(client_ip))
+ """
+ if ip_address is None:
+ return None
+ return _hash_ip(ip_address)
+
+
+def log_with_context(logger: BoundLogger, **context) -> BoundLogger:
+ """
+ Bind context to a logger for all subsequent log calls.
+
+ Useful for adding common context (like user_id, request_id) that
+ will be included in all logs within a scope.
+
+ Args:
+ logger: The logger to bind context to
+ **context: Key-value pairs to bind
+
+ Returns:
+ Logger with bound context
+
+ Example:
+ >>> from utils.logger import get_logger, log_with_context
+ >>> log = get_logger(__name__)
+ >>> log = log_with_context(log, user_id="123", request_id="req_abc")
+ >>> log.info("user_action") # Will include user_id and request_id
+ """
+ return logger.bind(**context)
diff --git a/utils/logging_config.py b/utils/logging_config.py
new file mode 100644
index 00000000..5e672690
--- /dev/null
+++ b/utils/logging_config.py
@@ -0,0 +1,252 @@
+"""
+Centralized logging configuration for spoo.me URL shortener.
+
+This module sets up structured logging with:
+- Environment-based configuration (dev vs production)
+- JSON formatting for production, pretty console for development
+- IP hashing for GDPR compliance in production
+- Sentry integration for error tracking
+- Sampling rate configuration for high-frequency events
+"""
+
+import os
+import sys
+import hashlib
+import logging
+
+import structlog
+from structlog.types import EventDict, Processor
+
+
+# Environment configuration
+ENV = os.getenv("ENV", "development")
+IS_PRODUCTION = ENV == "production"
+IS_DEVELOPMENT = ENV == "development"
+
+# Log level configuration
+LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO" if IS_PRODUCTION else "DEBUG")
+LOG_FORMAT = os.getenv("LOG_FORMAT", "json" if IS_PRODUCTION else "console")
+
+# Sampling rates for high-frequency events
+SAMPLING_RATES = {
+ "url_redirect": float(os.getenv("SAMPLE_RATE_REDIRECT", "0.05")), # 5%
+ "stats_query": float(os.getenv("SAMPLE_RATE_STATS", "0.20")), # 20%
+ "cache_operation": float(os.getenv("SAMPLE_RATE_CACHE", "0.01")), # 1%
+ "stats_export": float(os.getenv("SAMPLE_RATE_EXPORT", "0.80")), # 80%
+}
+
+# Sensitive fields to redact from logs
+REDACTED_FIELDS = {
+ "password",
+ "password_hash",
+ "token",
+ "api_key",
+ "Authorization",
+ "Cookie",
+ "refresh_token",
+ "access_token",
+ "secret",
+ "key",
+}
+
+
+def hash_ip(ip_address: str) -> str:
+ """
+ Hash IP address for privacy in production.
+
+ In production, returns SHA-256 hash (first 16 chars) for GDPR compliance.
+ In development, returns the original IP for easier debugging.
+
+ Args:
+ ip_address: The IP address to hash
+
+ Returns:
+ Hashed IP (production) or original IP (development)
+ """
+ if IS_PRODUCTION and ip_address:
+ return hashlib.sha256(ip_address.encode()).hexdigest()[:16]
+ return ip_address
+
+
+def add_log_level(
+ logger: logging.Logger, method_name: str, event_dict: EventDict
+) -> EventDict:
+ """Add log level to event dict."""
+ if method_name == "warn":
+ method_name = "warning"
+ event_dict["level"] = method_name
+ return event_dict
+
+
+def add_timestamp(
+ logger: logging.Logger, method_name: str, event_dict: EventDict
+) -> EventDict:
+ """Add ISO format timestamp to event dict."""
+ from datetime import datetime, timezone
+
+ event_dict["timestamp"] = datetime.now(timezone.utc).isoformat()
+ return event_dict
+
+
+def redact_sensitive_fields(
+ logger: logging.Logger, method_name: str, event_dict: EventDict
+) -> EventDict:
+ """Redact sensitive fields from logs."""
+ for key in list(event_dict.keys()):
+ if key.lower() in REDACTED_FIELDS or any(
+ sensitive in key.lower()
+ for sensitive in ["password", "token", "key", "secret"]
+ ):
+ if key not in ["level", "event", "timestamp", "logger"]:
+ event_dict[key] = "***REDACTED***"
+ return event_dict
+
+
+def filter_exceptions(
+ logger: logging.Logger, method_name: str, event_dict: EventDict
+) -> EventDict:
+ """Format exceptions properly for logging."""
+ exc_info = event_dict.pop("exc_info", None)
+ if exc_info:
+ event_dict["exception"] = structlog.processors.format_exc_info(
+ logger, method_name, {"exc_info": exc_info}
+ )["exception"]
+ return event_dict
+
+
+def configure_structlog() -> None:
+ """
+ Configure structlog with appropriate processors for the environment.
+
+ Production: JSON formatting for easy parsing
+ Development: Pretty console formatting with colors
+ """
+ # Shared processors for all environments
+ shared_processors: list[Processor] = [
+ structlog.contextvars.merge_contextvars,
+ structlog.stdlib.add_log_level,
+ structlog.stdlib.add_logger_name,
+ add_timestamp,
+ structlog.processors.TimeStamper(fmt="iso"),
+ structlog.stdlib.PositionalArgumentsFormatter(),
+ structlog.processors.StackInfoRenderer(),
+ redact_sensitive_fields,
+ filter_exceptions,
+ ]
+
+ if LOG_FORMAT == "json":
+ # Production: JSON output
+ structlog.configure(
+ processors=shared_processors
+ + [
+ structlog.processors.format_exc_info,
+ structlog.processors.JSONRenderer(),
+ ],
+ wrapper_class=structlog.stdlib.BoundLogger,
+ context_class=dict,
+ logger_factory=structlog.stdlib.LoggerFactory(),
+ cache_logger_on_first_use=True,
+ )
+ else:
+ # Development: Pretty console output with colors
+ structlog.configure(
+ processors=shared_processors
+ + [
+ structlog.processors.format_exc_info,
+ structlog.dev.ConsoleRenderer(
+ colors=True,
+ pad_event=15, # Reduced from default 30
+ sort_keys=False, # Don't sort keys, keep order
+ ),
+ ],
+ wrapper_class=structlog.stdlib.BoundLogger,
+ context_class=dict,
+ logger_factory=structlog.stdlib.LoggerFactory(),
+ cache_logger_on_first_use=True,
+ )
+
+
+def configure_stdlib_logging() -> None:
+ """
+ Configure standard library logging to work with structlog.
+
+ Sets up:
+ - Log level from environment
+ - Console handler for stdout
+ - Format compatible with structlog
+ """
+ logging.basicConfig(
+ format="%(message)s",
+ stream=sys.stdout,
+ level=getattr(logging, LOG_LEVEL.upper()),
+ )
+
+ # Reduce noise from third-party libraries
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
+ logging.getLogger("werkzeug").setLevel(logging.WARNING)
+ logging.getLogger("botocore").setLevel(logging.WARNING)
+ logging.getLogger("boto3").setLevel(logging.WARNING)
+
+
+def configure_sentry_logging() -> None:
+ """
+ Configure Sentry integration for error tracking.
+
+ If Sentry DSN is configured, this sets up:
+ - Automatic error capture for ERROR+ level logs
+ - Breadcrumbs for INFO+ level logs (context for errors)
+ - User context attachment
+ """
+ sentry_dsn = os.getenv("SENTRY_DSN")
+
+ if not sentry_dsn:
+ return
+
+ try:
+ from sentry_sdk.integrations.logging import LoggingIntegration
+
+ # Sentry logging integration
+ # INFO+ logs become breadcrumbs (context)
+ # ERROR+ logs become Sentry events
+ sentry_logging = LoggingIntegration( # noqa F841
+ level=logging.INFO, # Capture INFO and above as breadcrumbs
+ event_level=logging.ERROR, # Send ERROR and above as events
+ )
+
+ # Note: Sentry SDK initialization happens in main.py
+ # This just configures how logging integrates with it
+
+ except ImportError:
+ # Sentry SDK not installed, skip configuration
+ pass
+
+
+def setup_logging() -> None:
+ """
+ Initialize logging system for the application.
+
+ This is the main entry point for logging configuration.
+ Should be called early in application startup (in main.py).
+ """
+ # Configure standard library logging first
+ configure_stdlib_logging()
+
+ # Configure structlog
+ configure_structlog()
+
+ # Configure Sentry integration if available
+ configure_sentry_logging()
+
+ # Log initialization
+ logger = structlog.get_logger(__name__)
+ logger.info(
+ "logging_initialized",
+ env=ENV,
+ log_level=LOG_LEVEL,
+ log_format=LOG_FORMAT,
+ sentry_enabled=bool(os.getenv("SENTRY_DSN")),
+ )
+
+
+# Initialize logging when module is imported
+setup_logging()
diff --git a/utils/mongo_utils.py b/utils/mongo_utils.py
index 95b61e9c..7a029980 100644
--- a/utils/mongo_utils.py
+++ b/utils/mongo_utils.py
@@ -1,26 +1,36 @@
-from pymongo import MongoClient
+from pymongo import MongoClient, ASCENDING, DESCENDING
from dotenv import load_dotenv
import os
import re
+from bson import ObjectId
+from utils.url_utils import validate_emoji_alias
+from utils.logger import get_logger
-load_dotenv(override=True)
+load_dotenv()
+log = get_logger(__name__)
MONGO_URI = os.environ["MONGODB_URI"]
client = MongoClient(MONGO_URI)
try:
client.admin.command("ping")
- print("Pinged your deployment. You successfully connected to MongoDB!")
+ log.info("mongodb_connected")
except Exception as e:
- print(e)
+ log.error("mongodb_connection_failed", error=str(e), error_type=type(e).__name__)
+ raise
db = client["url-shortener"]
urls_collection = db["urls"]
+urls_v2_collection = db["urlsV2"]
+clicks_collection = db["clicks"]
blocked_urls_collection = db["blocked-urls"]
emoji_urls_collection = db["emojis"]
ip_bypasses = db["ip-exceptions"]
+users_collection = db["users"]
+api_keys_collection = db["api-keys"]
+verification_tokens_collection = db["verification-tokens"]
def load_url(id, projection=None):
@@ -111,3 +121,412 @@ def validate_blocked_url(url):
return False
return True
+
+
+def get_user_by_email(email, projection=None):
+ try:
+ user = users_collection.find_one({"email": email}, projection)
+ except Exception:
+ user = None
+ return user
+
+
+def get_user_by_oauth_provider(provider, provider_user_id, projection=None):
+ """Get user by OAuth provider and provider user ID"""
+ try:
+ user = users_collection.find_one(
+ {
+ "auth_providers.provider": provider,
+ "auth_providers.provider_user_id": provider_user_id,
+ },
+ projection,
+ )
+ except Exception:
+ user = None
+ return user
+
+
+def get_user_by_id(user_id, projection=None):
+ try:
+ user = users_collection.find_one({"_id": ObjectId(user_id)}, projection)
+ except Exception:
+ user = None
+ return user
+
+
+def create_user(user_data):
+ try:
+ result = users_collection.insert_one(user_data)
+ return result.inserted_id
+ except Exception:
+ return None
+
+
+def update_user(user_id, updates):
+ try:
+ users_collection.update_one({"_id": user_id}, updates)
+ except Exception:
+ pass
+
+
+# ===== v2 URL helpers =====
+
+
+def insert_url_v2(doc: dict):
+ try:
+ urls_v2_collection.insert_one(doc)
+ except Exception:
+ pass
+
+
+def get_url_v2_by_alias(alias: str, projection=None):
+ try:
+ return urls_v2_collection.find_one({"alias": alias}, projection)
+ except Exception:
+ return None
+
+
+def check_if_v2_alias_exists(alias: str) -> bool:
+ try:
+ doc = urls_v2_collection.find_one({"alias": alias}, {"_id": 1})
+ return doc is not None
+ except Exception:
+ return False
+
+
+def update_url_v2_clicks(url_id, last_click_time=None, increment_clicks=1):
+ """Atomically update total_clicks and last_click for a URL V2 document"""
+ try:
+ from datetime import datetime, timezone
+
+ result = urls_v2_collection.update_one(
+ {"_id": url_id},
+ {
+ "$inc": {"total_clicks": increment_clicks},
+ "$set": {"last_click": last_click_time or datetime.now(timezone.utc)},
+ },
+ )
+ return result
+ except Exception:
+ return None
+
+
+def expire_url_if_max_clicks_reached(url_id, max_clicks):
+ """Conditionally expire URL if max_clicks is reached"""
+ try:
+ result = urls_v2_collection.update_one(
+ {"_id": ObjectId(url_id), "total_clicks": {"$gte": max_clicks}},
+ {"$set": {"status": "EXPIRED"}},
+ )
+ return result
+ except Exception:
+ return None
+
+
+def insert_click_data(click_data):
+ """Insert click data into the time-series clicks collection"""
+ try:
+ # Ensure proper time-series schema with meta field
+ if "meta" not in click_data:
+ log.warning(
+ "click_data_missing_meta_field",
+ has_clicked_at="clicked_at" in click_data,
+ )
+ return False
+
+ if "clicked_at" not in click_data:
+ log.warning(
+ "click_data_missing_clicked_at_field", has_meta="meta" in click_data
+ )
+ return False
+
+ clicks_collection.insert_one(click_data)
+ return True
+ except Exception as e:
+ log.error(
+ "click_data_insert_failed",
+ error=str(e),
+ error_type=type(e).__name__,
+ has_meta="meta" in click_data if click_data else False,
+ has_clicked_at="clicked_at" in click_data if click_data else False,
+ )
+ return False
+
+
+def get_url_by_length_and_type(short_code):
+ """Determine URL schema based on length and fetch from appropriate collection"""
+ # First check if it's an emoji
+ if validate_emoji_alias(short_code):
+ return load_emoji_url(short_code), "emoji"
+
+ # Check length: 7 chars typically URLsV2, 6 chars typically old URLs
+ if len(short_code) == 7:
+ # Try URLsV2 first
+ url_data = get_url_v2_by_alias(short_code)
+ if url_data:
+ return url_data, "v2"
+ # Fallback to old schema
+ url_data = load_url(short_code)
+ if url_data:
+ return url_data, "v1"
+ elif len(short_code) == 6:
+ # Try old schema first
+ url_data = load_url(short_code)
+ if url_data:
+ return url_data, "v1"
+ # Fallback to URLsV2 (custom aliases)
+ url_data = get_url_v2_by_alias(short_code)
+ if url_data:
+ return url_data, "v2"
+ else:
+ # For other lengths, try both (custom aliases)
+ url_data = get_url_v2_by_alias(short_code)
+ if url_data:
+ return url_data, "v2"
+ url_data = load_url(short_code)
+ if url_data:
+ return url_data, "v1"
+
+ return None, None
+
+
+def get_url_v2_by_id(url_id, projection=None):
+ """Get a URL V2 document by its MongoDB ObjectId"""
+ try:
+ from bson import ObjectId
+
+ if isinstance(url_id, str):
+ url_id = ObjectId(url_id)
+ return urls_v2_collection.find_one({"_id": url_id}, projection)
+ except Exception:
+ return None
+
+
+def validate_url_ownership(url_id, owner_id):
+ """Validate that a URL belongs to the specified owner"""
+ try:
+ from bson import ObjectId
+
+ if isinstance(url_id, str):
+ url_id = ObjectId(url_id)
+ if isinstance(owner_id, str):
+ owner_id = ObjectId(owner_id)
+
+ url_doc = urls_v2_collection.find_one(
+ {"_id": url_id, "owner_id": owner_id}, {"_id": 1}
+ )
+ return url_doc is not None
+ except Exception:
+ return False
+
+
+def check_url_stats_privacy(short_code):
+ """Check if a URL's statistics are private or public
+
+ Returns:
+ dict: {"private": bool, "owner_id": str|None, "exists": bool}
+ """
+ try:
+ # First try V2 URLs (new schema)
+ url_doc = get_url_v2_by_alias(short_code, {"private_stats": 1, "owner_id": 1})
+ if url_doc:
+ private_stats = url_doc.get(
+ "private_stats", True
+ ) # Default to private if not set
+ owner_id = str(url_doc.get("owner_id")) if url_doc.get("owner_id") else None
+ return {"private": private_stats, "owner_id": owner_id, "exists": True}
+
+ # Fallback to V1 URLs (old schema) - these don't have private_stats field
+ # so they are considered public by default for backward compatibility
+ url_doc = load_url(short_code)
+ if url_doc:
+ return {"private": False, "owner_id": None, "exists": True}
+
+ # URL doesn't exist
+ return {"private": False, "owner_id": None, "exists": False}
+ except Exception:
+ return {"private": True, "owner_id": None, "exists": False} # Fail safe
+
+
+# ===== API Keys helpers =====
+
+
+def insert_api_key(doc: dict):
+ try:
+ result = api_keys_collection.insert_one(doc)
+ return result.inserted_id
+ except Exception:
+ return None
+
+
+def find_api_key_by_hash(token_hash: str, projection=None):
+ try:
+ doc = api_keys_collection.find_one({"token_hash": token_hash}, projection)
+ return doc
+ except Exception:
+ return None
+
+
+def list_api_keys_by_user(user_id, projection=None):
+ try:
+ uid = ObjectId(user_id) if not isinstance(user_id, ObjectId) else user_id
+ cur = api_keys_collection.find({"user_id": uid}, projection).sort(
+ "created_at", ASCENDING
+ )
+ return list(cur)
+ except Exception:
+ return []
+
+
+def revoke_api_key_by_id(user_id, key_id, *, hard_delete: bool = False) -> bool:
+ try:
+ uid = ObjectId(user_id) if not isinstance(user_id, ObjectId) else user_id
+ kid = ObjectId(key_id) if not isinstance(key_id, ObjectId) else key_id
+ if hard_delete:
+ result = api_keys_collection.delete_one({"_id": kid, "user_id": uid})
+ return result.deleted_count == 1
+ else:
+ result = api_keys_collection.update_one(
+ {"_id": kid, "user_id": uid}, {"$set": {"revoked": True}}
+ )
+ return result.modified_count == 1
+ except Exception:
+ return False
+
+
+def ensure_indexes():
+ try:
+ users_collection.create_index([("email", ASCENDING)], unique=True)
+
+ # OAuth provider indexes
+ users_collection.create_index(
+ [
+ ("auth_providers.provider", ASCENDING),
+ ("auth_providers.provider_user_id", ASCENDING),
+ ],
+ unique=True,
+ sparse=True,
+ )
+ users_collection.create_index([("auth_providers.provider", ASCENDING)])
+
+ # v2 urls indexes
+ urls_v2_collection.create_index([("alias", ASCENDING)], unique=True)
+ urls_v2_collection.create_index([("owner_id", ASCENDING)])
+ urls_v2_collection.create_index(
+ [
+ ("owner_id", ASCENDING),
+ ("created_at", DESCENDING),
+ ]
+ )
+ urls_v2_collection.create_index([("total_clicks", DESCENDING)])
+ urls_v2_collection.create_index([("last_click", DESCENDING)])
+
+ # Create time-series collection for clicks if it doesn't exist
+ try:
+ db.create_collection(
+ "clicks",
+ timeseries={
+ "timeField": "clicked_at",
+ "metaField": "meta",
+ "granularity": "seconds",
+ },
+ )
+ except Exception:
+ # Collection may already exist, that's fine
+ pass
+
+ # clicks collection indexes (time-series)
+ clicks_collection.create_index(
+ [
+ ("meta.url_id", ASCENDING),
+ ("clicked_at", DESCENDING),
+ ]
+ )
+ clicks_collection.create_index([("clicked_at", DESCENDING)])
+
+ # api keys indexes
+ api_keys_collection.create_index([("user_id", ASCENDING)])
+ api_keys_collection.create_index([("token_hash", ASCENDING)], unique=True)
+ # Optional TTL: remove when expires_at passes
+ api_keys_collection.create_index(
+ [("expires_at", ASCENDING)], expireAfterSeconds=0
+ )
+
+ # verification tokens indexes
+ verification_tokens_collection.create_index([("user_id", ASCENDING)])
+ verification_tokens_collection.create_index([("token_hash", ASCENDING)])
+ verification_tokens_collection.create_index([("token_type", ASCENDING)])
+ # TTL index: auto-delete expired tokens
+ verification_tokens_collection.create_index(
+ [("expires_at", ASCENDING)], expireAfterSeconds=0
+ )
+ except Exception:
+ pass
+
+
+# ===== Verification Tokens helpers =====
+
+
+def create_verification_token(token_data: dict):
+ """Create a new verification token"""
+ try:
+ result = verification_tokens_collection.insert_one(token_data)
+ return result.inserted_id
+ except Exception:
+ return None
+
+
+def get_verification_token(token_hash: str, token_type: str, projection=None):
+ """Get a verification token by hash and type"""
+ try:
+ return verification_tokens_collection.find_one(
+ {"token_hash": token_hash, "token_type": token_type, "used_at": None},
+ projection,
+ )
+ except Exception:
+ return None
+
+
+def mark_token_as_used(token_id):
+ """Mark a verification token as used"""
+ try:
+ from datetime import datetime, timezone
+
+ result = verification_tokens_collection.update_one(
+ {"_id": token_id}, {"$set": {"used_at": datetime.now(timezone.utc)}}
+ )
+ return result.modified_count > 0
+ except Exception:
+ return False
+
+
+def delete_user_tokens(user_id, token_type: str = None):
+ """Delete all tokens for a user, optionally filtered by type"""
+ try:
+ uid = ObjectId(user_id) if not isinstance(user_id, ObjectId) else user_id
+ query = {"user_id": uid}
+ if token_type:
+ query["token_type"] = token_type
+ result = verification_tokens_collection.delete_many(query)
+ return result.deleted_count
+ except Exception:
+ return 0
+
+
+def count_recent_tokens(user_id, token_type: str, minutes: int = 60):
+ """Count tokens created in the last N minutes for rate limiting"""
+ try:
+ from datetime import datetime, timezone, timedelta
+
+ uid = ObjectId(user_id) if not isinstance(user_id, ObjectId) else user_id
+ cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
+ count = verification_tokens_collection.count_documents(
+ {
+ "user_id": uid,
+ "token_type": token_type,
+ "created_at": {"$gte": cutoff},
+ }
+ )
+ return count
+ except Exception:
+ return 0
diff --git a/utils/oauth_utils.py b/utils/oauth_utils.py
new file mode 100644
index 00000000..cab4b28b
--- /dev/null
+++ b/utils/oauth_utils.py
@@ -0,0 +1,522 @@
+import os
+import secrets
+from datetime import datetime, timezone
+from typing import Dict, Optional, Tuple, Any, List
+
+from authlib.integrations.flask_client import OAuth
+from flask import url_for
+
+from utils.mongo_utils import users_collection
+from utils.url_utils import get_client_ip
+from utils.logger import get_logger
+
+log = get_logger(__name__)
+
+
+class OAuthProviders:
+ GOOGLE = "google"
+ GITHUB = "github"
+ DISCORD = "discord"
+ # Future providers can be added here
+
+
+def init_oauth(app):
+ """Initialize OAuth with Flask app"""
+ # Check if OAuth credentials are configured
+ google_client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
+ google_client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET")
+ github_client_id = os.getenv("GITHUB_OAUTH_CLIENT_ID")
+ github_client_secret = os.getenv("GITHUB_OAUTH_CLIENT_SECRET")
+ discord_client_id = os.getenv("DISCORD_OAUTH_CLIENT_ID")
+ discord_client_secret = os.getenv("DISCORD_OAUTH_CLIENT_SECRET")
+
+ oauth = OAuth(app)
+ providers = {}
+
+ # Google OAuth configuration
+ if google_client_id and google_client_secret:
+ try:
+ google = oauth.register(
+ name="google",
+ client_id=google_client_id,
+ client_secret=google_client_secret,
+ server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
+ client_kwargs={
+ "scope": "openid email profile",
+ "prompt": "select_account", # Always show account selector
+ },
+ )
+ providers["google"] = google
+ log.info("oauth_provider_initialized", provider="google")
+ except Exception as e:
+ log.error(
+ "oauth_provider_init_failed",
+ provider="google",
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+
+ # GitHub OAuth configuration
+ if github_client_id and github_client_secret:
+ try:
+ github = oauth.register(
+ name="github",
+ client_id=github_client_id,
+ client_secret=github_client_secret,
+ 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",
+ },
+ )
+ providers["github"] = github
+ log.info("oauth_provider_initialized", provider="github")
+ except Exception as e:
+ log.error(
+ "oauth_provider_init_failed",
+ provider="github",
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+
+ # Discord OAuth configuration
+ if discord_client_id and discord_client_secret:
+ try:
+ discord = oauth.register(
+ name="discord",
+ client_id=discord_client_id,
+ client_secret=discord_client_secret,
+ access_token_url="https://discord.com/api/oauth2/token",
+ authorize_url="https://discord.com/api/oauth2/authorize",
+ api_base_url="https://discord.com/api/",
+ client_kwargs={
+ "scope": "identify email",
+ },
+ )
+ providers["discord"] = discord
+ log.info("oauth_provider_initialized", provider="discord")
+ except Exception as e:
+ log.error(
+ "oauth_provider_init_failed",
+ provider="discord",
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+
+ if not providers:
+ log.warning("oauth_no_providers_configured")
+ return None, {}
+
+ return oauth, providers
+
+
+def generate_oauth_state(
+ provider: str, action: str = "login", user_id: Optional[str] = None
+) -> str:
+ """Generate a secure state parameter for OAuth flows
+
+ Args:
+ provider: OAuth provider name (e.g., 'google')
+ action: Action being performed ('login' or 'link')
+ user_id: Optional user ID for account linking (must be inside signed state)
+
+ Returns:
+ Encoded state string
+ """
+ state_data = {
+ "provider": provider,
+ "action": action,
+ "nonce": secrets.token_urlsafe(32),
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ }
+
+ # CRITICAL: Include user_id INSIDE the state before signing to prevent tampering
+ if user_id:
+ state_data["user_id"] = user_id
+
+ # For simplicity, we'll use a URL-safe encoding
+ # TODO: In production, you should sign this with a secret key for better security
+ # Consider using JWT or HMAC signing for the state parameter
+ state_parts = [
+ f"provider={state_data['provider']}",
+ f"action={state_data['action']}",
+ f"nonce={state_data['nonce']}",
+ f"timestamp={state_data['timestamp']}",
+ ]
+
+ # Include user_id in the state parts if present
+ if user_id:
+ state_parts.append(f"user_id={user_id}")
+
+ return "&".join(state_parts)
+
+
+def verify_oauth_state(
+ state: str, expected_provider: str
+) -> Tuple[bool, Dict[str, Any]]:
+ """Verify and decode OAuth state parameter
+
+ Args:
+ state: State parameter from OAuth callback
+ expected_provider: Expected provider name
+
+ Returns:
+ Tuple of (is_valid, state_data)
+ """
+ try:
+ # Parse state
+ state_data = {}
+ for part in state.split("&"):
+ if "=" in part:
+ key, value = part.split("=", 1)
+ state_data[key] = value
+
+ # Basic validation
+ if state_data.get("provider") != expected_provider:
+ return False, {}
+
+ # Check timestamp (state should not be older than 10 minutes)
+ timestamp_str = state_data.get("timestamp")
+ if timestamp_str:
+ timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
+ age = (datetime.now(timezone.utc) - timestamp).total_seconds()
+ if age > 600: # 10 minutes
+ return False, {}
+
+ return True, state_data
+ except Exception:
+ return False, {}
+
+
+def extract_user_info_from_google(userinfo: Dict[str, Any]) -> Dict[str, Any]:
+ """Extract standardized user information from Google OAuth response
+
+ Args:
+ userinfo: User info from Google OAuth
+
+ Returns:
+ Standardized user info dict
+ """
+ return {
+ "provider_user_id": userinfo.get("sub", ""),
+ "email": userinfo.get("email", "").lower().strip(),
+ "email_verified": userinfo.get("email_verified", False),
+ "name": userinfo.get("name", ""),
+ "picture": userinfo.get("picture", ""),
+ "given_name": userinfo.get("given_name", ""),
+ "family_name": userinfo.get("family_name", ""),
+ }
+
+
+def extract_user_info_from_github(
+ userinfo: Dict[str, Any], email_data: List[Dict[str, Any]]
+) -> Dict[str, Any]:
+ """Extract standardized user information from GitHub OAuth response
+
+ Args:
+ userinfo: User info from GitHub OAuth
+ email_data: Email data from GitHub API
+
+ Returns:
+ Standardized user info dict
+ """
+ # Find primary verified email
+ primary_email = None
+ email_verified = False
+
+ for email in email_data:
+ if email.get("primary", False):
+ primary_email = email.get("email", "").lower().strip()
+ email_verified = email.get("verified", False)
+ break
+
+ # Fallback to first email if no primary found
+ if not primary_email and email_data:
+ primary_email = email_data[0].get("email", "").lower().strip()
+ email_verified = email_data[0].get("verified", False)
+
+ return {
+ "provider_user_id": str(userinfo.get("id", "")),
+ "email": primary_email or "",
+ "email_verified": email_verified,
+ "name": userinfo.get("name", "") or userinfo.get("login", ""),
+ "picture": userinfo.get("avatar_url", ""),
+ "given_name": userinfo.get("name", "").split(" ")[0]
+ if userinfo.get("name")
+ else "",
+ "family_name": " ".join(userinfo.get("name", "").split(" ")[1:])
+ if userinfo.get("name") and " " in userinfo.get("name", "")
+ else "",
+ }
+
+
+def extract_user_info_from_discord(userinfo: Dict[str, Any]) -> Dict[str, Any]:
+ """Extract standardized user information from Discord OAuth response
+
+ Args:
+ userinfo: User info from Discord OAuth
+
+ Returns:
+ Standardized user info dict
+ """
+ # Discord provides email directly in the user object when email scope is granted
+ email = userinfo.get("email", "").lower().strip()
+ email_verified = userinfo.get("verified", False)
+
+ # Build full name from global_name (display name) or username
+ # Note: Discord removed discriminators, so we use global_name or username
+ name = (
+ userinfo.get("global_name")
+ or userinfo.get("display_name")
+ or userinfo.get("username", "")
+ )
+
+ # Build avatar URL
+ avatar_hash = userinfo.get("avatar")
+ user_id = userinfo.get("id", "")
+ avatar_url = ""
+ if avatar_hash and user_id:
+ # Discord CDN avatar URL format
+ avatar_url = f"https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png"
+
+ return {
+ "provider_user_id": str(userinfo.get("id", "")),
+ "email": email,
+ "email_verified": email_verified,
+ "name": name,
+ "picture": avatar_url,
+ "given_name": name.split(" ")[0] if name and " " in name else name,
+ "family_name": " ".join(name.split(" ")[1:]) if name and " " in name else "",
+ }
+
+
+def find_user_by_provider(
+ provider: str, provider_user_id: str
+) -> Optional[Dict[str, Any]]:
+ """Find user by OAuth provider and provider user ID
+
+ Args:
+ provider: OAuth provider name
+ provider_user_id: Provider's user ID
+
+ Returns:
+ User document or None
+ """
+ try:
+ return users_collection.find_one(
+ {
+ "auth_providers.provider": provider,
+ "auth_providers.provider_user_id": provider_user_id,
+ }
+ )
+ except Exception:
+ return None
+
+
+def create_oauth_user(provider_info: Dict[str, Any], provider: str) -> Optional[str]:
+ """Create a new user from OAuth provider information
+
+ Args:
+ provider_info: Standardized provider info
+ provider: Provider name
+
+ Returns:
+ User ID string or None if creation failed
+ """
+ try:
+ now = datetime.now(timezone.utc)
+
+ user_doc = {
+ "email": provider_info["email"],
+ "email_verified": provider_info["email_verified"],
+ "user_name": provider_info["name"] or provider_info["email"].split("@")[0],
+ "pfp": {
+ "url": provider_info["picture"],
+ "source": provider,
+ "last_updated": now,
+ }
+ if provider_info["picture"]
+ else None,
+ "password_hash": None,
+ "password_set": False,
+ "auth_providers": [
+ {
+ "provider": provider,
+ "provider_user_id": provider_info["provider_user_id"],
+ "email": provider_info["email"],
+ "email_verified": provider_info["email_verified"],
+ "profile": {
+ "name": provider_info["name"],
+ "picture": provider_info["picture"],
+ },
+ "linked_at": now,
+ }
+ ],
+ "plan": "free",
+ "signup_ip": get_client_ip(),
+ "created_at": now,
+ "updated_at": now,
+ "last_login_at": now,
+ "status": "ACTIVE",
+ }
+
+ result = users_collection.insert_one(user_doc)
+ return str(result.inserted_id)
+ except Exception as e:
+ log.error(
+ "oauth_user_creation_failed",
+ provider_id=provider_info.get("provider_user_id"),
+ provider=provider_info.get("provider"),
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return None
+
+
+def link_provider_to_user(
+ user_id: str, provider_info: Dict[str, Any], provider: str
+) -> bool:
+ """Link an OAuth provider to an existing user account
+
+ Args:
+ user_id: User's ID
+ provider_info: Standardized provider info
+ provider: Provider name
+
+ Returns:
+ True if linking succeeded, False otherwise
+ """
+ try:
+ from bson import ObjectId
+
+ now = datetime.now(timezone.utc)
+
+ provider_entry = {
+ "provider": provider,
+ "provider_user_id": provider_info["provider_user_id"],
+ "email": provider_info["email"],
+ "email_verified": provider_info["email_verified"],
+ "profile": {
+ "name": provider_info["name"],
+ "picture": provider_info["picture"],
+ },
+ "linked_at": now,
+ }
+
+ # Update user document
+ update_data = {
+ "$push": {"auth_providers": provider_entry},
+ "$set": {"updated_at": now, "last_login_at": now},
+ }
+
+ # Update profile picture if user doesn't have one or if they prefer provider's picture
+ if provider_info["picture"]:
+ update_data["$set"]["pfp"] = {
+ "url": provider_info["picture"],
+ "source": provider,
+ "last_updated": now,
+ }
+
+ # If email was not verified before but provider verifies it, update verification status
+ if provider_info["email_verified"]:
+ update_data["$set"]["email_verified"] = True
+
+ result = users_collection.update_one({"_id": ObjectId(user_id)}, update_data)
+
+ return result.modified_count > 0
+ except Exception as e:
+ log.error(
+ "oauth_provider_link_failed",
+ user_id=user_id,
+ provider=provider,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return False
+
+
+def can_auto_link_accounts(
+ existing_user: Dict[str, Any], provider_info: Dict[str, Any], provider: str
+) -> bool:
+ """Determine if we can automatically link accounts based on email verification
+
+ Args:
+ existing_user: Existing user document
+ provider_info: Provider info from OAuth
+ provider: Provider name
+
+ Returns:
+ True if accounts can be auto-linked
+ """
+ # Only auto-link if:
+ # 1. Provider email is verified
+ # 2. Emails match exactly
+ # 3. User doesn't already have this provider linked
+
+ if not provider_info.get("email_verified", False):
+ return False
+
+ if existing_user.get("email", "").lower() != provider_info.get("email", "").lower():
+ return False
+
+ # Check if provider is already linked
+ auth_providers = existing_user.get("auth_providers", [])
+ for provider_entry in auth_providers:
+ if provider_entry.get("provider") == provider:
+ return False
+
+ return True
+
+
+def update_user_last_login(user_id: str) -> None:
+ """Update user's last login timestamp
+
+ Args:
+ user_id: User's ID
+ """
+ try:
+ from bson import ObjectId
+
+ users_collection.update_one(
+ {"_id": ObjectId(user_id)},
+ {"$set": {"last_login_at": datetime.now(timezone.utc)}},
+ )
+ except Exception as e:
+ log.error(
+ "oauth_last_login_update_failed",
+ user_id=user_id,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+
+
+def get_oauth_redirect_url(provider: str, action: str = "login") -> str:
+ """Generate OAuth redirect URL for the given provider
+
+ First checks for environment variable {PROVIDER}_OAUTH_REDIRECT_URI,
+ then falls back to dynamic generation using Flask's url_for.
+
+ Args:
+ provider: OAuth provider name
+ action: Action being performed ('login' or 'link')
+
+ Returns:
+ Full redirect URL
+ """
+ # Check for environment variable first
+ env_var_name = f"{provider.upper()}_OAUTH_REDIRECT_URI"
+ env_redirect_uri = os.getenv(env_var_name)
+
+ if env_redirect_uri:
+ return env_redirect_uri
+
+ # Fall back to dynamic generation
+ if provider == OAuthProviders.GOOGLE:
+ return url_for("oauth.oauth_google_callback", _external=True)
+ elif provider == OAuthProviders.GITHUB:
+ return url_for("oauth.oauth_github_callback", _external=True)
+ elif provider == OAuthProviders.DISCORD:
+ return url_for("oauth.oauth_discord_callback", _external=True)
+
+ raise ValueError(f"Unknown OAuth provider: {provider}")
diff --git a/utils/password_utils.py b/utils/password_utils.py
new file mode 100644
index 00000000..e4c78b29
--- /dev/null
+++ b/utils/password_utils.py
@@ -0,0 +1,95 @@
+import re
+from typing import List, Tuple
+
+
+def validate_password(password: str) -> Tuple[bool, List[str], int]:
+ """
+ Validate password and return validation status, missing requirements, and strength score.
+
+ Returns:
+ Tuple[bool, List[str], int]: (is_valid, missing_requirements, strength_score)
+ """
+ if not password:
+ return False, ["Password is required"], 0
+
+ missing = []
+ strength_score = 0
+
+ # Basic requirements
+ if len(password) < 8:
+ missing.append("At least 8 characters")
+ else:
+ strength_score += 20
+
+ if len(password) > 128:
+ missing.append("Maximum 128 characters")
+ else:
+ strength_score += 10
+
+ if not re.search(r"[A-Z]", password):
+ missing.append("At least one uppercase letter")
+ else:
+ strength_score += 15
+
+ if not re.search(r"[a-z]", password):
+ missing.append("At least one lowercase letter")
+ else:
+ strength_score += 15
+
+ if not re.search(r"[0-9]", password):
+ missing.append("At least one number")
+ else:
+ strength_score += 15
+
+ if not re.search(r'[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?~`]', password):
+ missing.append("At least one special character")
+ else:
+ strength_score += 15
+
+ # Check for malicious characters - only allow safe characters
+ if not re.match(
+ r'^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?~`\s]+$', password
+ ):
+ missing.append("Contains invalid characters")
+ else:
+ strength_score += 10
+
+ # Additional strength bonuses
+ if len(password) >= 12:
+ strength_score += 5
+ if len(password) >= 16:
+ strength_score += 5
+
+ # Check for common weak patterns
+ if re.search(r"(.)\1{2,}", password): # 3+ repeated characters
+ strength_score -= 10
+
+ if re.search(
+ r"(012|123|234|345|456|567|678|789|890|abc|bcd|cde|def)", password.lower()
+ ):
+ strength_score -= 15
+
+ # Common weak passwords
+ weak_patterns = [r"password", r"123456", r"qwerty", r"admin", r"login", r"welcome"]
+
+ for pattern in weak_patterns:
+ if re.search(pattern, password.lower()):
+ strength_score -= 20
+ break
+
+ is_valid = len(missing) == 0
+
+ return is_valid, missing
+
+
+def get_password_requirements() -> List[str]:
+ """Get list of all password requirements."""
+ return [
+ "At least 8 characters",
+ "Maximum 128 characters",
+ "At least one uppercase letter",
+ "At least one lowercase letter",
+ "At least one number",
+ "At least one special character",
+ "Only safe characters allowed",
+ ]
diff --git a/utils/query_builder.py b/utils/query_builder.py
new file mode 100644
index 00000000..fff3adcb
--- /dev/null
+++ b/utils/query_builder.py
@@ -0,0 +1,134 @@
+from typing import Dict, Any, Optional, List
+from datetime import datetime
+from bson import ObjectId
+
+from utils.logger import get_logger
+
+log = get_logger(__name__)
+
+
+class StatsQueryBuilder:
+ """Builder pattern for constructing MongoDB queries for statistics"""
+
+ def __init__(self):
+ self.query: Dict[str, Any] = {}
+ self.time_filters: Dict[str, datetime] = {}
+ self.scope_filters: Dict[str, Any] = {}
+ self.dimension_filters: Dict[str, List[str]] = {}
+
+ def with_scope(
+ self,
+ owner_id: Optional[str],
+ scope: str,
+ short_code: Optional[str] = None,
+ ) -> "StatsQueryBuilder":
+ """Add scope-based filtering to the query"""
+ if scope == "all" and owner_id:
+ self.scope_filters["meta.owner_id"] = (
+ ObjectId(owner_id) if isinstance(owner_id, str) else owner_id
+ )
+ elif scope == "anon" and short_code:
+ self.scope_filters["meta.short_code"] = short_code
+
+ return self
+
+ def with_time_range(
+ self, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None
+ ) -> "StatsQueryBuilder":
+ """Add time range filtering to the query"""
+ if start_date:
+ self.time_filters["$gte"] = start_date
+ if end_date:
+ self.time_filters["$lte"] = end_date
+
+ return self
+
+ def with_filters(self, filters: Dict[str, List[str]]) -> "StatsQueryBuilder":
+ """Add dimension-based filtering to the query"""
+ for dimension, values in filters.items():
+ if values:
+ self.dimension_filters[dimension] = values
+
+ return self
+
+ def build(self) -> Dict[str, Any]:
+ """Build the final MongoDB query"""
+ # Start with scope filters
+ self.query.update(self.scope_filters)
+
+ # Add time range filters
+ if self.time_filters:
+ self.query["clicked_at"] = self.time_filters
+
+ # Add dimension filters
+ for dimension, values in self.dimension_filters.items():
+ if dimension == "short_code":
+ # SECURITY: Only apply short_code filter if not already set by scope
+ # This prevents filter-based scope bypass attacks
+ if "meta.short_code" in self.scope_filters:
+ # Skip - short_code already locked by scope (anon mode)
+ log.warning(
+ "query_builder_scope_bypass_prevented",
+ dimension="short_code",
+ locked_short_code=self.scope_filters.get("meta.short_code"),
+ attempted_values=values,
+ )
+ continue
+ # Map "short_code" filter to the actual field name in MongoDB
+ self.query["meta.short_code"] = {"$in": values}
+ elif dimension == "referrer":
+ # Handle special case for "Direct" referrers (null/missing referrer)
+ if "Direct" in values:
+ # Split Direct and non-Direct referrers
+ non_direct_values = [v for v in values if v != "Direct"]
+
+ if non_direct_values:
+ # Both Direct and specific referrers requested
+ self.query["$or"] = [
+ {"referrer": {"$in": non_direct_values}},
+ {"referrer": {"$in": [None, ""]}},
+ {"referrer": {"$exists": False}},
+ ]
+ else:
+ # Only Direct referrers requested
+ self.query["$or"] = [
+ {"referrer": {"$in": [None, ""]}},
+ {"referrer": {"$exists": False}},
+ ]
+ else:
+ # No Direct referrers, normal filtering
+ self.query[dimension] = {"$in": values}
+ else:
+ self.query[dimension] = {"$in": values}
+
+ return self.query
+
+
+class StatsQueryBuilderFactory:
+ """Factory for creating pre-configured query builders"""
+
+ @staticmethod
+ def for_user_stats(
+ owner_id: str,
+ start_date: Optional[datetime] = None,
+ end_date: Optional[datetime] = None,
+ ) -> StatsQueryBuilder:
+ """Create a query builder for all user statistics"""
+ return (
+ StatsQueryBuilder()
+ .with_scope(owner_id, "all")
+ .with_time_range(start_date, end_date)
+ )
+
+ @staticmethod
+ def for_anonymous_stats(
+ short_code: str,
+ start_date: Optional[datetime] = None,
+ end_date: Optional[datetime] = None,
+ ) -> StatsQueryBuilder:
+ """Create a query builder for anonymous URL statistics"""
+ return (
+ StatsQueryBuilder()
+ .with_scope(None, "anon", short_code=short_code)
+ .with_time_range(start_date, end_date)
+ )
diff --git a/utils/stats_utils.py b/utils/stats_utils.py
new file mode 100644
index 00000000..c7518c59
--- /dev/null
+++ b/utils/stats_utils.py
@@ -0,0 +1,264 @@
+from datetime import datetime, timedelta, timezone
+from typing import Dict, List, Any, Optional
+import functools
+
+
+def normalize_time_series_data(
+ data: List[Dict[str, Any]],
+ start_date: datetime,
+ end_date: datetime,
+ date_field: str = "date",
+) -> List[Dict[str, Any]]:
+ """
+ Fill missing dates in time series data with zero values
+
+ Args:
+ data: List of dictionaries containing time series data
+ start_date: Start date for the range
+ end_date: End date for the range
+ date_field: Name of the date field in the data
+
+ Returns:
+ List with all dates filled, missing dates have zero values
+ """
+ if not data:
+ return []
+
+ # Convert data to dictionary for quick lookup
+ data_dict = {item[date_field]: item for item in data}
+
+ # Generate date range
+ current_date = start_date.date()
+ end_date_only = end_date.date()
+ normalized_data = []
+
+ while current_date <= end_date_only:
+ date_str = current_date.strftime("%Y-%m-%d")
+ if date_str in data_dict:
+ normalized_data.append(data_dict[date_str])
+ else:
+ # Create zero entry
+ zero_entry = {date_field: date_str}
+ # Add zero values for all numeric fields
+ for key, value in data[0].items():
+ if key != date_field and isinstance(value, (int, float)):
+ zero_entry[key] = 0
+ elif key != date_field:
+ zero_entry[key] = value if isinstance(value, str) else "Unknown"
+ normalized_data.append(zero_entry)
+
+ current_date += timedelta(days=1)
+
+ return normalized_data
+
+
+def aggregate_top_n_with_others(
+ data: List[Dict[str, Any]], metric_field: str, dimension_field: str, top_n: int = 5
+) -> List[Dict[str, Any]]:
+ """
+ Aggregate data to show top N items and group the rest as "Others"
+
+ Args:
+ data: List of dictionaries containing the data
+ metric_field: Field name containing the metric to aggregate
+ dimension_field: Field name containing the dimension values
+ top_n: Number of top items to show individually
+
+ Returns:
+ List with top N items and "Others" entry if applicable
+ """
+ if len(data) <= top_n:
+ return data
+
+ # Sort by metric in descending order
+ sorted_data = sorted(data, key=lambda x: x.get(metric_field, 0), reverse=True)
+
+ # Take top N items
+ top_items = sorted_data[:top_n]
+
+ # Aggregate remaining items
+ others_sum = sum(item.get(metric_field, 0) for item in sorted_data[top_n:])
+
+ if others_sum > 0:
+ others_entry = {dimension_field: "Others", metric_field: others_sum}
+ # Copy other fields from first item as template
+ for key, value in sorted_data[0].items():
+ if key not in [dimension_field, metric_field]:
+ if isinstance(value, (int, float)):
+ others_entry[key] = sum(
+ item.get(key, 0) for item in sorted_data[top_n:]
+ )
+ else:
+ others_entry[key] = "Others"
+ top_items.append(others_entry)
+
+ return top_items
+
+
+def calculate_growth_metrics(
+ current_data: List[Dict[str, Any]],
+ previous_data: List[Dict[str, Any]],
+ metric_field: str,
+) -> Dict[str, float]:
+ """
+ Calculate growth metrics comparing current period to previous period
+
+ Args:
+ current_data: Data for current period
+ previous_data: Data for previous period
+ metric_field: Field name containing the metric to compare
+
+ Returns:
+ Dictionary containing growth metrics
+ """
+ current_total = sum(item.get(metric_field, 0) for item in current_data)
+ previous_total = sum(item.get(metric_field, 0) for item in previous_data)
+
+ if previous_total == 0:
+ growth_rate = float("inf") if current_total > 0 else 0
+ growth_percentage = 100.0 if current_total > 0 else 0.0
+ else:
+ growth_rate = (current_total - previous_total) / previous_total
+ growth_percentage = growth_rate * 100
+
+ return {
+ "current_total": current_total,
+ "previous_total": previous_total,
+ "absolute_change": current_total - previous_total,
+ "growth_rate": growth_rate,
+ "growth_percentage": round(growth_percentage, 2),
+ }
+
+
+@functools.lru_cache(maxsize=128)
+def get_country_name_from_code(country_code: str) -> str:
+ """
+ Convert ISO country code to country name with caching
+
+ Args:
+ country_code: ISO 2-letter country code
+
+ Returns:
+ Country name or the original code if not found
+ """
+ try:
+ import pycountry
+
+ country = pycountry.countries.get(alpha_2=country_code.upper())
+ return country.name if country else country_code
+ except ImportError:
+ # Fallback if pycountry is not available
+ country_map = {
+ "US": "United States",
+ "GB": "United Kingdom",
+ "CA": "Canada",
+ "AU": "Australia",
+ "DE": "Germany",
+ "FR": "France",
+ "JP": "Japan",
+ "CN": "China",
+ "IN": "India",
+ "BR": "Brazil",
+ }
+ return country_map.get(country_code.upper(), country_code)
+
+
+def format_stats_response_with_metadata(
+ stats_data: Dict[str, Any], include_metadata: bool = True
+) -> Dict[str, Any]:
+ """
+ Format stats response with additional metadata and computed fields
+
+ Args:
+ stats_data: Raw stats data from the query builder
+ include_metadata: Whether to include additional metadata
+
+ Returns:
+ Enhanced stats response with metadata
+ """
+ if not include_metadata:
+ return stats_data
+
+ response = stats_data.copy()
+
+ # Add response metadata
+ response["generated_at"] = datetime.now(timezone.utc).isoformat()
+ response["api_version"] = "v1"
+
+ # Calculate additional metrics from summary if available
+ summary = response.get("summary", {})
+ if summary:
+ total_clicks = summary.get("total_clicks", 0)
+ unique_clicks = summary.get("unique_clicks", 0)
+
+ # Calculate click-through rate approximation
+ if total_clicks > 0:
+ unique_rate = (unique_clicks / total_clicks) * 100
+ response["computed_metrics"] = {
+ "unique_click_rate": round(unique_rate, 2),
+ "repeat_click_rate": round(100 - unique_rate, 2),
+ "average_clicks_per_visitor": round(
+ total_clicks / unique_clicks if unique_clicks > 0 else 0, 2
+ ),
+ }
+
+ # Enhance metrics data with percentages
+ metrics = response.get("metrics", {})
+ for metric_key, metric_data in metrics.items():
+ if isinstance(metric_data, list) and metric_data:
+ total = sum(
+ item.get(list(item.keys())[-1], 0)
+ for item in metric_data
+ if isinstance(item, dict)
+ )
+ if total > 0:
+ for item in metric_data:
+ if isinstance(item, dict):
+ value_key = list(item.keys())[
+ -1
+ ] # Get the last key (usually the metric value)
+ value = item.get(value_key, 0)
+ item[f"{value_key}_percentage"] = round(
+ (value / total) * 100, 2
+ )
+
+ return response
+
+
+def validate_date_range(
+ start_date: Optional[datetime], end_date: Optional[datetime], max_days: int = 90
+) -> Dict[str, Any]:
+ """
+ Validate date range parameters
+
+ Args:
+ start_date: Start date
+ end_date: End date
+ max_days: Maximum allowed days in range
+
+ Returns:
+ Validation result with is_valid flag and error message if invalid
+ """
+ if not start_date and not end_date:
+ return {"is_valid": True}
+
+ if start_date and end_date:
+ if start_date > end_date:
+ return {"is_valid": False, "error": "start_date must be before end_date"}
+
+ date_range = (end_date - start_date).days
+ if date_range > max_days:
+ return {
+ "is_valid": False,
+ "error": f"date range cannot exceed {max_days} days",
+ }
+
+ # Check if dates are in the future
+ now = datetime.now(timezone.utc)
+ if start_date and start_date.replace(microsecond=0) > now.replace(microsecond=0):
+ return {"is_valid": False, "error": "start_date cannot be in the future"}
+
+ if end_date and end_date.replace(microsecond=0) > now.replace(microsecond=0):
+ return {"is_valid": False, "error": "end_date cannot be in the future"}
+
+ return {"is_valid": True}
diff --git a/utils/time_bucket_utils.py b/utils/time_bucket_utils.py
new file mode 100644
index 00000000..d52d341d
--- /dev/null
+++ b/utils/time_bucket_utils.py
@@ -0,0 +1,442 @@
+"""
+Utility functions for determining optimal time bucket intervals based on date ranges.
+
+This module provides dynamic time bucketing strategies for analytics data aggregation,
+optimizing granularity based on the time span being analyzed.
+"""
+
+from datetime import datetime, timedelta
+from typing import Dict, Any, List
+from enum import Enum
+
+
+class TimeBucketStrategy(Enum):
+ """Enumeration of available time bucketing strategies"""
+
+ MINUTE_10 = "10_minute"
+ HOURLY = "hourly"
+ DAILY = "daily"
+ WEEKLY = "weekly"
+ MONTHLY = "monthly"
+
+
+class TimeBucketConfig:
+ """Configuration for time bucket aggregation"""
+
+ def __init__(
+ self,
+ strategy: TimeBucketStrategy,
+ mongo_format: str,
+ interval_minutes: int,
+ display_format: str = None,
+ ):
+ self.strategy = strategy
+ self.mongo_format = mongo_format
+ self.interval_minutes = interval_minutes
+ self.display_format = display_format or mongo_format
+
+
+# Time bucket configurations for different strategies
+BUCKET_CONFIGS = {
+ TimeBucketStrategy.MINUTE_10: TimeBucketConfig(
+ strategy=TimeBucketStrategy.MINUTE_10,
+ mongo_format="%Y-%m-%d %H:%M",
+ interval_minutes=10,
+ display_format="%Y-%m-%d %H:%M",
+ ),
+ TimeBucketStrategy.HOURLY: TimeBucketConfig(
+ strategy=TimeBucketStrategy.HOURLY,
+ mongo_format="%Y-%m-%d %H:00",
+ interval_minutes=60,
+ display_format="%Y-%m-%d %H:00",
+ ),
+ TimeBucketStrategy.DAILY: TimeBucketConfig(
+ strategy=TimeBucketStrategy.DAILY,
+ mongo_format="%Y-%m-%d",
+ interval_minutes=1440, # 24 * 60
+ display_format="%Y-%m-%d",
+ ),
+ TimeBucketStrategy.WEEKLY: TimeBucketConfig(
+ strategy=TimeBucketStrategy.WEEKLY,
+ mongo_format="%Y-W%U", # Year-Week format
+ interval_minutes=10080, # 7 * 24 * 60
+ display_format="%Y-W%U",
+ ),
+ TimeBucketStrategy.MONTHLY: TimeBucketConfig(
+ strategy=TimeBucketStrategy.MONTHLY,
+ mongo_format="%Y-%m",
+ interval_minutes=43200, # Approximate: 30 * 24 * 60
+ display_format="%Y-%m",
+ ),
+}
+
+
+def determine_optimal_bucket_strategy(
+ start_date: datetime, end_date: datetime
+) -> TimeBucketStrategy:
+ """
+ Determine the optimal time bucket strategy based on the date range.
+
+ Strategy Rules:
+ - < 1 hour: 10-minute buckets
+ - ≤ 24 hours: hourly buckets
+ - > 24 hours: daily buckets (for trend analysis up to several months)
+ - Future: monthly buckets may be added for yearly retention analytics
+
+ Args:
+ start_date: Start of the time range
+ end_date: End of the time range
+
+ Returns:
+ TimeBucketStrategy: The recommended bucketing strategy
+ """
+ if not start_date or not end_date:
+ return TimeBucketStrategy.DAILY
+
+ time_delta = end_date - start_date
+ total_hours = time_delta.total_seconds() / 3600
+
+ # < 1 hour: 10-minute buckets
+ if total_hours <= 1:
+ return TimeBucketStrategy.MINUTE_10
+
+ # ≤ 24 hours: hourly buckets
+ elif total_hours <= 24:
+ return TimeBucketStrategy.HOURLY
+
+ # > 24 hours: daily buckets (covers everything from days to months)
+ else:
+ return TimeBucketStrategy.DAILY
+
+
+def get_bucket_config(strategy: TimeBucketStrategy) -> TimeBucketConfig:
+ """Get the bucket configuration for a given strategy"""
+ return BUCKET_CONFIGS[strategy]
+
+
+def get_optimal_bucket_config(
+ start_date: datetime, end_date: datetime
+) -> TimeBucketConfig:
+ """
+ Get the optimal bucket configuration based on date range.
+
+ Args:
+ start_date: Start of the time range
+ end_date: End of the time range
+
+ Returns:
+ TimeBucketConfig: Configuration for the optimal bucketing strategy
+ """
+ strategy = determine_optimal_bucket_strategy(start_date, end_date)
+ return get_bucket_config(strategy)
+
+
+def create_mongo_time_bucket_pipeline(
+ bucket_config: TimeBucketConfig,
+ clicked_at_field: str = "clicked_at",
+ timezone: str = "UTC",
+) -> Dict[str, Any]:
+ """
+ Create MongoDB aggregation pipeline stage for time bucketing.
+
+ For 10-minute buckets, we need special handling to round down to 10-minute intervals.
+
+ Args:
+ bucket_config: The bucket configuration to use
+ clicked_at_field: Name of the datetime field in the collection
+ timezone: IANA timezone for bucketing (default: UTC)
+
+ Returns:
+ Dict containing the MongoDB aggregation stage for time bucketing
+ """
+ if bucket_config.strategy == TimeBucketStrategy.MINUTE_10:
+ # For 10-minute buckets, extract parts in target timezone, round minutes, then format
+ return {
+ "$dateToString": {
+ "format": "%Y-%m-%d %H:%M",
+ "date": {
+ "$dateFromParts": {
+ "year": {
+ "$year": {
+ "date": f"${clicked_at_field}",
+ "timezone": timezone,
+ }
+ },
+ "month": {
+ "$month": {
+ "date": f"${clicked_at_field}",
+ "timezone": timezone,
+ }
+ },
+ "day": {
+ "$dayOfMonth": {
+ "date": f"${clicked_at_field}",
+ "timezone": timezone,
+ }
+ },
+ "hour": {
+ "$hour": {
+ "date": f"${clicked_at_field}",
+ "timezone": timezone,
+ }
+ },
+ "minute": {
+ "$multiply": [
+ {
+ "$floor": {
+ "$divide": [
+ {
+ "$minute": {
+ "date": f"${clicked_at_field}",
+ "timezone": timezone,
+ }
+ },
+ 10,
+ ]
+ }
+ },
+ 10,
+ ]
+ },
+ "timezone": timezone,
+ }
+ },
+ "timezone": timezone,
+ }
+ }
+ else:
+ # For other strategies, use standard dateToString with timezone
+ return {
+ "$dateToString": {
+ "format": bucket_config.mongo_format,
+ "date": f"${clicked_at_field}",
+ "timezone": timezone,
+ }
+ }
+
+
+def format_time_bucket_display(
+ bucket_value: str, bucket_config: TimeBucketConfig
+) -> str:
+ """
+ Format time bucket value for display purposes.
+
+ Args:
+ bucket_value: The raw bucket value from aggregation
+ bucket_config: The bucket configuration used
+
+ Returns:
+ Formatted string for display
+ """
+ try:
+ if bucket_config.strategy == TimeBucketStrategy.MINUTE_10:
+ # For 10-minute buckets, ensure we show the interval
+ dt = datetime.strptime(bucket_value, "%Y-%m-%d %H:%M")
+ return dt.strftime("%Y-%m-%d %H:%M")
+
+ elif bucket_config.strategy == TimeBucketStrategy.HOURLY:
+ # For hourly buckets, ensure we show the hour
+ if ":" not in bucket_value:
+ bucket_value += " 00:00"
+ dt = datetime.strptime(bucket_value, "%Y-%m-%d %H:%M")
+ return dt.strftime("%Y-%m-%d %H:00")
+
+ elif bucket_config.strategy == TimeBucketStrategy.WEEKLY:
+ # For weekly buckets, convert to a more readable format
+ # MongoDB %U gives week number, we might want to enhance this
+ return bucket_value
+
+ else:
+ # For daily and monthly, return as-is
+ return bucket_value
+
+ except (ValueError, TypeError):
+ # If parsing fails, return original value
+ return bucket_value
+
+
+def estimate_bucket_count(
+ start_date: datetime, end_date: datetime, bucket_config: TimeBucketConfig
+) -> int:
+ """
+ Estimate the number of buckets that will be generated for a date range.
+
+ Useful for performance considerations and UI pagination.
+
+ Args:
+ start_date: Start of the time range
+ end_date: End of the time range
+ bucket_config: The bucket configuration
+
+ Returns:
+ Estimated number of buckets
+ """
+ if not start_date or not end_date:
+ return 0
+
+ time_delta = end_date - start_date
+ total_minutes = time_delta.total_seconds() / 60
+
+ return max(1, int(total_minutes / bucket_config.interval_minutes))
+
+
+def get_bucket_strategy_info() -> Dict[str, Dict[str, Any]]:
+ """
+ Get information about all available bucketing strategies.
+
+ Useful for API documentation and frontend configuration.
+
+ Returns:
+ Dictionary with strategy information
+ """
+ return {
+ strategy.value: {
+ "name": strategy.value,
+ "mongo_format": config.mongo_format,
+ "display_format": config.display_format,
+ "interval_minutes": config.interval_minutes,
+ "description": _get_strategy_description(strategy),
+ }
+ for strategy, config in BUCKET_CONFIGS.items()
+ }
+
+
+def generate_complete_time_buckets(
+ start_date: datetime, end_date: datetime, bucket_config: TimeBucketConfig
+) -> List[str]:
+ """
+ Generate a complete list of time buckets for a given date range.
+
+ This ensures that all time periods are represented in the response,
+ even if there are no clicks during those periods.
+
+ Args:
+ start_date: Start of the time range
+ end_date: End of the time range
+ bucket_config: The bucket configuration to use
+
+ Returns:
+ List of formatted time bucket strings
+ """
+ buckets = []
+ current = start_date
+
+ if bucket_config.strategy == TimeBucketStrategy.MINUTE_10:
+ # Round start time down to nearest 10 minutes
+ current = current.replace(second=0, microsecond=0)
+ current = current.replace(minute=(current.minute // 10) * 10)
+
+ while current <= end_date:
+ bucket_str = current.strftime("%Y-%m-%d %H:%M")
+ buckets.append(bucket_str)
+ current += timedelta(minutes=10)
+
+ elif bucket_config.strategy == TimeBucketStrategy.HOURLY:
+ # Round start time down to nearest hour
+ current = current.replace(minute=0, second=0, microsecond=0)
+
+ while current <= end_date:
+ bucket_str = current.strftime("%Y-%m-%d %H:00")
+ buckets.append(bucket_str)
+ current += timedelta(hours=1)
+
+ elif bucket_config.strategy == TimeBucketStrategy.DAILY:
+ # Round start time down to start of day
+ current = current.replace(hour=0, minute=0, second=0, microsecond=0)
+
+ while current <= end_date:
+ bucket_str = current.strftime("%Y-%m-%d")
+ buckets.append(bucket_str)
+ current += timedelta(days=1)
+
+ elif bucket_config.strategy == TimeBucketStrategy.WEEKLY:
+ # Round start time down to start of week (Monday)
+ days_since_monday = current.weekday()
+ current = current.replace(hour=0, minute=0, second=0, microsecond=0)
+ current = current - timedelta(days=days_since_monday)
+
+ while current <= end_date:
+ bucket_str = current.strftime("%Y-W%U")
+ buckets.append(bucket_str)
+ current += timedelta(weeks=1)
+
+ elif bucket_config.strategy == TimeBucketStrategy.MONTHLY:
+ # Round start time down to start of month
+ current = current.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
+
+ while current <= end_date:
+ bucket_str = current.strftime("%Y-%m")
+ buckets.append(bucket_str)
+ # Move to next month
+ if current.month == 12:
+ current = current.replace(year=current.year + 1, month=1)
+ else:
+ current = current.replace(month=current.month + 1)
+
+ return buckets
+
+
+def fill_missing_buckets(
+ actual_results: List[Dict[str, Any]],
+ start_date: datetime,
+ end_date: datetime,
+ bucket_config: TimeBucketConfig,
+) -> List[Dict[str, Any]]:
+ """
+ Fill in missing time buckets with zero values.
+
+ This ensures continuous time series data even when there are no clicks
+ for certain time periods.
+
+ Args:
+ actual_results: Results from MongoDB aggregation
+ start_date: Start of the time range
+ end_date: End of the time range
+ bucket_config: The bucket configuration used
+
+ Returns:
+ Complete list of results with missing periods filled with zeros
+ """
+ if not actual_results:
+ actual_results = []
+
+ # Generate all expected buckets
+ all_buckets = generate_complete_time_buckets(start_date, end_date, bucket_config)
+
+ # Create a lookup map of actual results - use raw_bucket for matching
+ actual_map = {}
+ for result in actual_results:
+ # Use the date field for matching (already formatted)
+ bucket_key = result.get("date", "")
+ actual_map[bucket_key] = result
+
+ # Fill in complete results
+ complete_results = []
+ for bucket in all_buckets:
+ if bucket in actual_map:
+ # Use actual data
+ complete_results.append(actual_map[bucket])
+ else:
+ # Fill with zero values
+ zero_result = {
+ "date": bucket, # Use the generated bucket directly
+ "total_clicks": 0,
+ "unique_clicks": 0,
+ "bucket_strategy": bucket_config.strategy.value,
+ "raw_bucket": bucket,
+ }
+ complete_results.append(zero_result)
+
+ return complete_results
+
+
+def _get_strategy_description(strategy: TimeBucketStrategy) -> str:
+ """Get human-readable description for a bucketing strategy"""
+ descriptions = {
+ TimeBucketStrategy.MINUTE_10: "10-minute intervals for real-time analysis",
+ TimeBucketStrategy.HOURLY: "Hourly intervals for daily pattern analysis",
+ TimeBucketStrategy.DAILY: "Daily intervals for trend analysis",
+ TimeBucketStrategy.WEEKLY: "Weekly intervals for long-term trends",
+ TimeBucketStrategy.MONTHLY: "Monthly intervals for yearly comparisons",
+ }
+ return descriptions.get(strategy, "Unknown strategy")
diff --git a/utils/url_utils.py b/utils/url_utils.py
index b3af3df8..357e329a 100644
--- a/utils/url_utils.py
+++ b/utils/url_utils.py
@@ -29,6 +29,22 @@ def get_country(ip_address):
reader.close()
+def get_city_cf(request):
+ return request.headers.get("CF-IPCity", None)
+
+
+def get_city(ip_address):
+ reader = geoip2.database.Reader("misc/GeoLite2-City.mmdb")
+ try:
+ response = reader.city(ip_address)
+ city = response.city.name
+ return city
+ except geoip2.errors.AddressNotFoundError:
+ return "Unknown"
+ finally:
+ reader.close()
+
+
def get_client_ip() -> str:
# Check for common proxy headers first
headers_to_check: list[str] = [
@@ -83,27 +99,14 @@ def validate_expiration_time(expiration_time):
expiration_time = datetime.fromisoformat(expiration_time)
# Check if it's timezone aware
if expiration_time.tzinfo is None:
- print("timezone not aware")
return False
else:
- print("timezone aware")
- print("Expiration Time in GMT: ", expiration_time.astimezone(timezone.utc))
- print(expiration_time.tzinfo)
# Convert to GMT if it's timezone aware
expiration_time = expiration_time.astimezone(timezone.utc)
if expiration_time < datetime.now(timezone.utc) + timedelta(minutes=3):
- print(expiration_time, datetime.now(timezone.utc) + timedelta(minutes=3))
- print("EXPIRATION TIME IN GMT: ", expiration_time)
- print("CURRENT TIME IN GMT: ", datetime.now(timezone.utc))
- print(
- "CURRENT TIME IN GMT + 5: ",
- datetime.now(timezone.utc) + timedelta(minutes=4.5),
- )
- print("less than 5 minutes")
return False
return True
- except Exception as e:
- print(e)
+ except Exception:
return False
@@ -123,6 +126,11 @@ def generate_short_code():
return "".join(random.choice(letters) for i in range(6))
+def generate_short_code_v2(length: int = 7):
+ letters = string.ascii_lowercase + string.ascii_uppercase + string.digits
+ return "".join(random.choice(letters) for _ in range(length))
+
+
def validate_alias(string):
pattern = r"^[a-zA-Z0-9_-]*$"
return bool(re.search(pattern, string))
diff --git a/utils/verification_utils.py b/utils/verification_utils.py
new file mode 100644
index 00000000..38e94f34
--- /dev/null
+++ b/utils/verification_utils.py
@@ -0,0 +1,344 @@
+"""
+Utilities for email verification and password reset tokens/OTPs
+"""
+
+import secrets
+import hashlib
+import string
+from datetime import datetime, timezone, timedelta
+from typing import Tuple, Optional
+from bson import ObjectId
+
+from utils.logger import get_logger
+from utils.mongo_utils import (
+ create_verification_token,
+ get_verification_token,
+ mark_token_as_used,
+ delete_user_tokens,
+ count_recent_tokens,
+)
+
+log = get_logger(__name__)
+
+# Token types
+TOKEN_TYPE_EMAIL_VERIFY = "email_verify"
+TOKEN_TYPE_PASSWORD_RESET = "password_reset"
+
+# Expiry times (in seconds)
+OTP_EXPIRY_SECONDS = 600 # 10 minutes
+TOKEN_EXPIRY_SECONDS = 900 # 15 minutes
+
+# Rate limiting
+MAX_TOKENS_PER_HOUR = 3
+MAX_VERIFICATION_ATTEMPTS = 5
+
+
+def generate_otp_code(length: int = 6) -> str:
+ """
+ Generate a random numeric OTP code
+
+ Args:
+ length: Length of the OTP (default 6)
+
+ Returns:
+ String of random digits
+ """
+ return "".join(secrets.choice(string.digits) for _ in range(length))
+
+
+def generate_secure_token(length: int = 32) -> str:
+ """
+ Generate a cryptographically secure random token
+
+ Args:
+ length: Length in bytes (default 32)
+
+ Returns:
+ URL-safe base64 encoded token
+ """
+ return secrets.token_urlsafe(length)
+
+
+def hash_token(token: str) -> str:
+ """
+ Hash a token using SHA256
+
+ Args:
+ token: Plain token to hash
+
+ Returns:
+ Hex-encoded SHA256 hash
+ """
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
+
+
+def create_email_verification_otp(
+ user_id: str, email: str
+) -> Tuple[bool, Optional[str], Optional[str]]:
+ """
+ Create an email verification OTP for a user
+
+ Args:
+ user_id: User's MongoDB ObjectId as string
+ email: User's email address
+
+ Returns:
+ Tuple of (success, otp_code, error_message)
+ """
+ try:
+ # Rate limiting check
+ recent_count = count_recent_tokens(user_id, TOKEN_TYPE_EMAIL_VERIFY, 60)
+ if recent_count >= MAX_TOKENS_PER_HOUR:
+ log.warning(
+ "verification_rate_limited",
+ user_id=user_id,
+ token_type=TOKEN_TYPE_EMAIL_VERIFY,
+ count=recent_count,
+ )
+ return (
+ False,
+ None,
+ "Too many verification attempts. Please try again later.",
+ )
+
+ # Generate OTP
+ otp_code = generate_otp_code()
+ otp_hash = hash_token(otp_code)
+
+ # Calculate expiry
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=OTP_EXPIRY_SECONDS)
+
+ # Create token document
+ token_data = {
+ "user_id": ObjectId(user_id),
+ "email": email,
+ "token_hash": otp_hash,
+ "token_type": TOKEN_TYPE_EMAIL_VERIFY,
+ "expires_at": expires_at,
+ "created_at": datetime.now(timezone.utc),
+ "used_at": None,
+ "attempts": 0,
+ }
+
+ # Save to database
+ token_id = create_verification_token(token_data)
+
+ if not token_id:
+ log.error("verification_token_creation_failed", user_id=user_id)
+ return False, None, "Failed to create verification token"
+
+ log.info(
+ "verification_token_created",
+ user_id=user_id,
+ token_id=str(token_id),
+ token_type=TOKEN_TYPE_EMAIL_VERIFY,
+ )
+
+ return True, otp_code, None
+
+ except Exception as e:
+ log.error(
+ "verification_token_creation_error",
+ user_id=user_id,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return False, None, "An error occurred while creating verification token"
+
+
+def create_password_reset_otp(
+ user_id: str, email: str
+) -> Tuple[bool, Optional[str], Optional[str]]:
+ """
+ Create a password reset OTP for a user
+
+ Args:
+ user_id: User's MongoDB ObjectId as string
+ email: User's email address
+
+ Returns:
+ Tuple of (success, otp_code, error_message)
+ """
+ try:
+ # Rate limiting check
+ recent_count = count_recent_tokens(user_id, TOKEN_TYPE_PASSWORD_RESET, 60)
+ if recent_count >= MAX_TOKENS_PER_HOUR:
+ log.warning(
+ "password_reset_rate_limited",
+ user_id=user_id,
+ token_type=TOKEN_TYPE_PASSWORD_RESET,
+ count=recent_count,
+ )
+ return (
+ False,
+ None,
+ "Too many password reset attempts. Please try again later.",
+ )
+
+ # Delete any existing password reset tokens for this user
+ delete_user_tokens(user_id, TOKEN_TYPE_PASSWORD_RESET)
+
+ # Generate OTP
+ otp_code = generate_otp_code()
+ otp_hash = hash_token(otp_code)
+
+ # Calculate expiry
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=OTP_EXPIRY_SECONDS)
+
+ # Create token document
+ token_data = {
+ "user_id": ObjectId(user_id),
+ "email": email,
+ "token_hash": otp_hash,
+ "token_type": TOKEN_TYPE_PASSWORD_RESET,
+ "expires_at": expires_at,
+ "created_at": datetime.now(timezone.utc),
+ "used_at": None,
+ "attempts": 0,
+ }
+
+ # Save to database
+ token_id = create_verification_token(token_data)
+
+ if not token_id:
+ log.error("password_reset_token_creation_failed", user_id=user_id)
+ return False, None, "Failed to create password reset token"
+
+ log.info(
+ "password_reset_token_created",
+ user_id=user_id,
+ token_id=str(token_id),
+ token_type=TOKEN_TYPE_PASSWORD_RESET,
+ )
+
+ return True, otp_code, None
+
+ except Exception as e:
+ log.error(
+ "password_reset_token_creation_error",
+ user_id=user_id,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return False, None, "An error occurred while creating password reset token"
+
+
+def verify_otp(
+ user_id: str, otp_code: str, token_type: str
+) -> Tuple[bool, Optional[str]]:
+ """
+ Verify an OTP code for a user
+
+ Args:
+ user_id: User's MongoDB ObjectId as string
+ otp_code: The OTP code to verify
+ token_type: Type of token (email_verify or password_reset)
+
+ Returns:
+ Tuple of (success, error_message)
+ """
+ try:
+ otp_hash = hash_token(otp_code)
+
+ # Find the token
+ token_doc = get_verification_token(otp_hash, token_type)
+
+ if not token_doc:
+ log.warning(
+ "otp_verification_failed",
+ user_id=user_id,
+ reason="token_not_found",
+ token_type=token_type,
+ )
+ return False, "Invalid or expired verification code"
+
+ # Check if token belongs to the user
+ if str(token_doc["user_id"]) != user_id:
+ log.warning(
+ "otp_verification_failed",
+ user_id=user_id,
+ reason="user_mismatch",
+ token_type=token_type,
+ )
+ return False, "Invalid verification code"
+
+ # Check if token is expired
+ # Ensure both datetimes are timezone-aware for comparison
+ expires_at = token_doc["expires_at"]
+ if not expires_at.tzinfo:
+ # If the stored datetime is naive, assume it's UTC
+ expires_at = expires_at.replace(tzinfo=timezone.utc)
+
+ if expires_at <= datetime.now(timezone.utc):
+ log.warning(
+ "otp_verification_failed",
+ user_id=user_id,
+ reason="expired",
+ token_type=token_type,
+ )
+ return False, "Verification code has expired"
+
+ # Check if already used
+ if token_doc.get("used_at"):
+ log.warning(
+ "otp_verification_failed",
+ user_id=user_id,
+ reason="already_used",
+ token_type=token_type,
+ )
+ return False, "Verification code has already been used"
+
+ # Check max attempts (stored in token doc)
+ if token_doc.get("attempts", 0) >= MAX_VERIFICATION_ATTEMPTS:
+ log.warning(
+ "otp_verification_failed",
+ user_id=user_id,
+ reason="max_attempts",
+ token_type=token_type,
+ )
+ return False, "Too many failed attempts. Please request a new code."
+
+ # Mark token as used
+ if not mark_token_as_used(token_doc["_id"]):
+ log.error(
+ "otp_mark_used_failed",
+ user_id=user_id,
+ token_id=str(token_doc["_id"]),
+ )
+ return False, "Failed to verify code"
+
+ log.info(
+ "otp_verified_success",
+ user_id=user_id,
+ token_id=str(token_doc["_id"]),
+ token_type=token_type,
+ )
+
+ return True, None
+
+ except Exception as e:
+ log.error(
+ "otp_verification_error",
+ user_id=user_id,
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return False, "An error occurred during verification"
+
+
+def is_rate_limited(user_id: str, token_type: str) -> bool:
+ """
+ Check if a user is rate limited for a specific token type
+
+ Args:
+ user_id: User's MongoDB ObjectId as string
+ token_type: Type of token to check
+
+ Returns:
+ True if rate limited, False otherwise
+ """
+ try:
+ recent_count = count_recent_tokens(user_id, token_type, 60)
+ return recent_count >= MAX_TOKENS_PER_HOUR
+ except Exception:
+ return False
diff --git a/uv.lock b/uv.lock
index 743a0735..e6c75d81 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,6 +1,11 @@
version = 1
-revision = 2
+revision = 3
requires-python = ">=3.9"
+resolution-markers = [
+ "python_full_version >= '3.14'",
+ "python_full_version >= '3.10' and python_full_version < '3.14'",
+ "python_full_version < '3.10'",
+]
[[package]]
name = "aiohappyeyeballs"
@@ -13,7 +18,7 @@ wheels = [
[[package]]
name = "aiohttp"
-version = "3.12.4"
+version = "3.13.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -25,105 +30,188 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/57/77/92b356837fad83cc5709afc0b6e21dce65a413293fed15e6999bafdf36b0/aiohttp-3.12.4.tar.gz", hash = "sha256:d8229b412121160740f5745583c786f3f494d2416fe5f76aabd815da6ab6b193", size = 7781788, upload-time = "2025-05-29T01:36:57.715Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c1/3b/07a1e596f7abd46f1482f056fe28933e66c98ad9ad695c9f31d9f2b37b22/aiohttp-3.12.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:437b9255b470e9dbeb1475b333297ff35c2ef2d5e60735238b0967572936bafa", size = 694881, upload-time = "2025-05-29T01:33:48.322Z" },
- { url = "https://files.pythonhosted.org/packages/f1/62/a5023b2a2c6a3e9fac4c268a5c7c6fdc6e6e969580d2f11804dea2928140/aiohttp-3.12.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1d3af7a8905c87b585f534e5e33e5ecf1a8264c3531f7436329c11b2e952788a", size = 471251, upload-time = "2025-05-29T01:33:52.189Z" },
- { url = "https://files.pythonhosted.org/packages/8c/15/a43fb3198aa8d6fe7b864057133699be5d42caa670af9f0288341bd7af30/aiohttp-3.12.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:18dead0d68a236a475fb6464f6fcc5330fc5e9ee4156c5846780a88f8b739d18", size = 459019, upload-time = "2025-05-29T01:33:54.127Z" },
- { url = "https://files.pythonhosted.org/packages/db/0d/b25a6a3b3c0fee6fe9471c027239341b81a9ad8f9b0d527e3586f0d76d97/aiohttp-3.12.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:520bb505f13ad3397e28d03e52d7bbbbb196f5bab49276bb264b3ce6f0fb57c0", size = 1641076, upload-time = "2025-05-29T01:33:56.145Z" },
- { url = "https://files.pythonhosted.org/packages/86/b2/894b266ec21d7c18f9ca581ca52c4464c791cf6533e04664728f501ad56c/aiohttp-3.12.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:92cb0f7857fe12d029ee5078d243c59b242f6dfb190a6d46238e375c69bcb797", size = 1615130, upload-time = "2025-05-29T01:33:58.293Z" },
- { url = "https://files.pythonhosted.org/packages/c9/5d/59c810044cbffe70be8b49e8b92fc45949484d9027a4aa200921f972e319/aiohttp-3.12.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4e4354d75d2b3988b50ca366974a448d2ee636085fb3091ce2361f9aad7c0bb7", size = 1687536, upload-time = "2025-05-29T01:34:00.693Z" },
- { url = "https://files.pythonhosted.org/packages/0c/a9/c65aa446dbe281c4b557c30899dd3e4716333f0328d63e65c5e66d6aa206/aiohttp-3.12.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29cfeb097a025efee3ea6eeb7ce2f75ea90008abac508a37775530c4e71a2d17", size = 1729851, upload-time = "2025-05-29T01:34:02.863Z" },
- { url = "https://files.pythonhosted.org/packages/08/36/13c2b7329e9049acc8d5bb7c237a55622b01148a7727ecb69b050b127f24/aiohttp-3.12.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b6d4caaba6b1658b1f3cf17348d76b313376cccd5a5892e471e24fefdf5ed59", size = 1634517, upload-time = "2025-05-29T01:34:05.165Z" },
- { url = "https://files.pythonhosted.org/packages/53/f5/b7c4734b783ac5111d748e6057959bb2169ce9b65e225846ad4bb27b3b9c/aiohttp-3.12.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d5750aa8a26d27280ca7db81d426a0b7e7bbb36280f0ad4bfaf0a0ee8a0d4ec0", size = 1574640, upload-time = "2025-05-29T01:34:07.313Z" },
- { url = "https://files.pythonhosted.org/packages/bc/c8/e301552530c43fc0821ba7f00fcbf879180d943d228c5d578dd2ea9c1d3f/aiohttp-3.12.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f4e7b557b41eccc0e5f792bc55f6eed9f669dfd9220babefbf0bddad17980c48", size = 1618488, upload-time = "2025-05-29T01:34:09.623Z" },
- { url = "https://files.pythonhosted.org/packages/79/7a/879405d4bb962c6860ecebb4e34e99387a24712511e75a3142e17b35d7ec/aiohttp-3.12.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:2ce301584f6e90bbb5f19b54a99797511c135f980b083e21d688c3927f9f03a8", size = 1629275, upload-time = "2025-05-29T01:34:11.962Z" },
- { url = "https://files.pythonhosted.org/packages/68/2e/4399734a6d8a194f88ce40f678abee7b9b32adf68c2a9a2977d1e93a433c/aiohttp-3.12.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:adff2f5a4aa7e11751b439d0de091f7cb74a3567cae97f91a9e371005e50792f", size = 1604727, upload-time = "2025-05-29T01:34:14.145Z" },
- { url = "https://files.pythonhosted.org/packages/29/eb/a7f4ddd80a934df8dd1e96fbaaaec37c7d314d563660b3df5a2de7f8f65c/aiohttp-3.12.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ee88d58b60ad65c755a11452bf630114f72725f13cd5acb00b183fbbb53bb3ef", size = 1684313, upload-time = "2025-05-29T01:34:16.467Z" },
- { url = "https://files.pythonhosted.org/packages/c1/52/fcd1b59668627e108c6f7195ebfb30ff342ea5ff3d2616005092e4230c0c/aiohttp-3.12.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68d39e3c8a7368cd2ab0b70ebbd80a2de6860079270f550ded37b597b815a9da", size = 1707551, upload-time = "2025-05-29T01:34:18.164Z" },
- { url = "https://files.pythonhosted.org/packages/87/da/3d7ff2cf8594916e98f4fd13771a33d700f038f330f56d21cbca7e37e54e/aiohttp-3.12.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d22596530780156f3292022ee380c21e37c8f9402b38cc456bcdc17e915632d9", size = 1635892, upload-time = "2025-05-29T01:34:19.928Z" },
- { url = "https://files.pythonhosted.org/packages/71/a7/39beaba9905d653972e4fd3bd6775d62458bc2d0ceed3099d47a35844547/aiohttp-3.12.4-cp310-cp310-win32.whl", hash = "sha256:05c89a13a371dcb938fbffa4b7226df9058d9f73c051b56b68acb499383d0221", size = 420202, upload-time = "2025-05-29T01:34:22.144Z" },
- { url = "https://files.pythonhosted.org/packages/70/97/335c4a7180aec0c9deae862d4d866b978f1bd2179ba8889f480afeb88449/aiohttp-3.12.4-cp310-cp310-win_amd64.whl", hash = "sha256:cae4c77621077a74db3874420b0d2a76bf98ef4c340767752fc7b0766d97cdb4", size = 443411, upload-time = "2025-05-29T01:34:24.223Z" },
- { url = "https://files.pythonhosted.org/packages/e9/5e/bd16acce20e07e01d7db8f9a5102714f90928f87ec9cb248db642893ebdf/aiohttp-3.12.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6cfe7a78ed06047420f7709b9ae438431ea2dc50a9c00960a4b996736f1a70a3", size = 702194, upload-time = "2025-05-29T01:34:25.982Z" },
- { url = "https://files.pythonhosted.org/packages/65/1d/cc50b39ca7a24c28e5e79ec7c5a3682c84af76d814f2e1284e1aa473122c/aiohttp-3.12.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1188186a118a6793b1e510399f5deb2dcab9643af05fd5217f7f5b067b863671", size = 474473, upload-time = "2025-05-29T01:34:28.245Z" },
- { url = "https://files.pythonhosted.org/packages/52/6b/bf1ff91cb6eda30964c29a7fbe2a294db00724ceab344696eeebfe4c9ccf/aiohttp-3.12.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d54362f38f532869553a38328931f5f150f0f4fdbee8e122da447663a86552c5", size = 462734, upload-time = "2025-05-29T01:34:29.887Z" },
- { url = "https://files.pythonhosted.org/packages/7c/c3/846872117cc6db1db1b86d20119a3132b8519144d5e710c2e066d07cac86/aiohttp-3.12.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4299504448f37ea9803e6ec99295d7a84a66e674300daa51ca69cace8b7ae31a", size = 1732930, upload-time = "2025-05-29T01:34:31.576Z" },
- { url = "https://files.pythonhosted.org/packages/d0/bd/df557ee83c3e36945499317b9f51dab642c17c779c939fe2df4c0307b85e/aiohttp-3.12.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1972bac2ee5dc283ccee3d58501bba08599d58dad6dbbbf58da566dc1a3ac039", size = 1681599, upload-time = "2025-05-29T01:34:33.59Z" },
- { url = "https://files.pythonhosted.org/packages/1b/b9/e043c06325300644fed7685f904323ecf937adc99971ac229ab97b0769d2/aiohttp-3.12.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a286d40eb51d2908130b4e64ca8ae1a1fdf20657ef564eea2556255d52e2147b", size = 1780391, upload-time = "2025-05-29T01:34:35.474Z" },
- { url = "https://files.pythonhosted.org/packages/6c/98/a43da221916db0b9567914e41de5a7e008904b9301540614feab2a03ee45/aiohttp-3.12.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94650ff81e7370ceb79272914be8250558d595864cb0cc3e9c6932a16738e33b", size = 1819437, upload-time = "2025-05-29T01:34:37.458Z" },
- { url = "https://files.pythonhosted.org/packages/bb/9d/e315bdfc2e8ba0382699e686330b588f135189c51df79689e6a843513eb0/aiohttp-3.12.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03a2ca7b7e9436ae933d89d41f21ef535f21dcdc883820544102ddda63b595c2", size = 1721898, upload-time = "2025-05-29T01:34:39.297Z" },
- { url = "https://files.pythonhosted.org/packages/c1/a4/8250493ab4e540df5a3672e5d01c28ca71fd31b4a9afc217c9678ca350e3/aiohttp-3.12.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea47b02ec80408bed4d59b3b824b44514173e4ebd0bc04a901ffd12084142451", size = 1658974, upload-time = "2025-05-29T01:34:41.114Z" },
- { url = "https://files.pythonhosted.org/packages/94/d3/06c8ba3afb270afa44ffb6cf3fb0a44502be347f0fc7fdce290a60760197/aiohttp-3.12.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:41a6ea58ed974e67d75b39536997d81288a04844d8162194d3947cbff52b093d", size = 1707245, upload-time = "2025-05-29T01:34:43.002Z" },
- { url = "https://files.pythonhosted.org/packages/da/5c/d889d8edca8cdb6bb0ff9cfa58b3977320186050c8cfe2f4ceeee149b498/aiohttp-3.12.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d563387ae8966b6668162698a66495c5d72ce864405a7dfc6cc9c4bc851a63ce", size = 1702405, upload-time = "2025-05-29T01:34:44.904Z" },
- { url = "https://files.pythonhosted.org/packages/e9/db/809ac0c7fa7ddfad33ab888fe3c83aecbfc7f03e44f387a70c20a0a096b7/aiohttp-3.12.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b853c7f7664742d48c57f382ebae5c76efa7f323569c6d93866795092485deec", size = 1682593, upload-time = "2025-05-29T01:34:46.792Z" },
- { url = "https://files.pythonhosted.org/packages/35/85/9e1f9c7f0b0f70dfae55932c1f080230f885f84137132efc639e98611347/aiohttp-3.12.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5d74f5fadbab802c598b440b4aecfeadc99194535d87db5764b732a52a0527fb", size = 1776193, upload-time = "2025-05-29T01:34:49.155Z" },
- { url = "https://files.pythonhosted.org/packages/83/12/b6b7b9c2d08c5346473878575195468a585041daa816ffbd97156c960ed0/aiohttp-3.12.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f5065674d38b4a738f38b344429e3688fdcccc9d2d5ec50ca03af5dbf91307e", size = 1796654, upload-time = "2025-05-29T01:34:51.588Z" },
- { url = "https://files.pythonhosted.org/packages/b7/09/0500ae6b1174abc74ab1a7a36033ecffc11e46e47a23487d75fa00d04b46/aiohttp-3.12.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:567db7411a004acd82be2499c10a22e06d4acb51929ce353a62f02f61d005e1c", size = 1709713, upload-time = "2025-05-29T01:34:53.579Z" },
- { url = "https://files.pythonhosted.org/packages/7b/55/8f5faa6e13c51609430081b42c39eb12006c9fb9111eeaedca0f3f574d3b/aiohttp-3.12.4-cp311-cp311-win32.whl", hash = "sha256:4bc000b0eee7c4b8fdc13349ab106c4ff15e6f6c1afffb04a8f5af96f1b89af3", size = 419713, upload-time = "2025-05-29T01:34:55.368Z" },
- { url = "https://files.pythonhosted.org/packages/6a/a9/97e318bfb3fc7a0cffc9dee9f0ec77db5339207887f5f4ebe1a11ecd5f32/aiohttp-3.12.4-cp311-cp311-win_amd64.whl", hash = "sha256:44f1cb869916ba52b7876243b6bb7841430846b66b61933b8e96cfaf44515b78", size = 444103, upload-time = "2025-05-29T01:34:57.133Z" },
- { url = "https://files.pythonhosted.org/packages/6c/9a/767c8f6520d0ad023d6b975f8fda71b506f64ad597bb7bd16fa5ac1562ca/aiohttp-3.12.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7947933c67eb33f51076cabf99f9977260329759d66c4d779c6b8e35c71a96bf", size = 693297, upload-time = "2025-05-29T01:34:58.922Z" },
- { url = "https://files.pythonhosted.org/packages/82/a1/21eddeee169306c974095183c8820a807c3f05dbefcd6b674a52d18e4090/aiohttp-3.12.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bb046723c90db9ecba67549ab5614707168ba7424742cfab40c198d8d75176e4", size = 467909, upload-time = "2025-05-29T01:35:00.746Z" },
- { url = "https://files.pythonhosted.org/packages/0d/fc/17093fe2d7e4287218fb99b18a6106b0e1fad8a95f974066f8b5fefb0fbc/aiohttp-3.12.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5fe52157c5e160eac99bb3589c2f29186d233fc83f6f42315c828f7e115f87f5", size = 460750, upload-time = "2025-05-29T01:35:03.193Z" },
- { url = "https://files.pythonhosted.org/packages/f8/4f/6ea71dd61725bdaa9437f1a9f032781c5d869046651ad43a93d769855298/aiohttp-3.12.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5bf2015822cf7177957b8573a5997c3a00b93cd2f40aa8f5155649014563bd8", size = 1707546, upload-time = "2025-05-29T01:35:05.059Z" },
- { url = "https://files.pythonhosted.org/packages/cc/79/a91f52b0d4e4462ebf37b176164d0f26b065f80f7db1dfe9b44fd9e8f8ac/aiohttp-3.12.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db28a058b837c2a8cbebd0fae78299a41691694e536bb2ad77377bd4978b8372", size = 1690196, upload-time = "2025-05-29T01:35:07.045Z" },
- { url = "https://files.pythonhosted.org/packages/d5/e2/5682bfb2583b55f23d785084bf2237339ebebe73cc0734fa8848d33a270c/aiohttp-3.12.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac155f380e100825fe2ae59b5d4e297fea98d90f5b7df5b27a9096992d8672dd", size = 1745291, upload-time = "2025-05-29T01:35:09.648Z" },
- { url = "https://files.pythonhosted.org/packages/90/1d/5016430fa2ed0d58ca6d6b0f4a1f929c353f72996c95ec33882cd18ed867/aiohttp-3.12.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2de98a1fa249d35f05a6a7525e5823260e8b0c252d72c9cf39d0f945c38da0c7", size = 1791444, upload-time = "2025-05-29T01:35:12.427Z" },
- { url = "https://files.pythonhosted.org/packages/2b/49/33fd3f82ff187b6d982633962afad24bb459ee1cd357399b7545c8e6ed98/aiohttp-3.12.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4c2de2077ee70b93015b4a74493964d891e730d238371c8d4b70413be36b0cf", size = 1710885, upload-time = "2025-05-29T01:35:15Z" },
- { url = "https://files.pythonhosted.org/packages/d5/11/e895cb33fca34cec9aa375615ba0d4810a3be601962066444b07a90bc306/aiohttp-3.12.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:058199018d700883c86c473814fb0ecabb4e3ae39bafcbc77ed2c94199e5affb", size = 1626686, upload-time = "2025-05-29T01:35:17.76Z" },
- { url = "https://files.pythonhosted.org/packages/b2/e9/3c98778dbda7cb4c94ddada97cb9ea6d7d5140b487a0444817f8b6a94697/aiohttp-3.12.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b6586aaccf46bc5ae05598fcd09a26fbc9186284eb2551d3262f31a8ec79a463", size = 1687746, upload-time = "2025-05-29T01:35:19.754Z" },
- { url = "https://files.pythonhosted.org/packages/45/7b/fdb43d32ac2819e181e1339aae1bc7acb87e47452af64409181a2bce2426/aiohttp-3.12.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ededddd6fcc8f4403135609d7fb4bc1c1300464ff8fd57fb097b08cc136f18ea", size = 1709199, upload-time = "2025-05-29T01:35:21.752Z" },
- { url = "https://files.pythonhosted.org/packages/bb/d9/b7a37bed158bd4aced1585b89082a8642e516f5b08637d7d15971f61ba31/aiohttp-3.12.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:158495f1d1858c07cc691624ccc92498410edfa57900452948f7eb6bc1be4c39", size = 1649853, upload-time = "2025-05-29T01:35:24.718Z" },
- { url = "https://files.pythonhosted.org/packages/42/4f/7e4d1c52f6e15c59e2f3154d9431a029aab558735e94fec85602207fee8a/aiohttp-3.12.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:41c064200045c344850688b4d7723ebf163b92bfc7c216c29a938d1051385c1c", size = 1729413, upload-time = "2025-05-29T01:35:26.847Z" },
- { url = "https://files.pythonhosted.org/packages/94/83/2987339271a4d8915370614d0bd6b26b7e50d905adf7398636a278ca059a/aiohttp-3.12.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0834ec8491451780a2a05b0f3a83675911bb0804273ceafcd282bff2548ed962", size = 1757386, upload-time = "2025-05-29T01:35:29.605Z" },
- { url = "https://files.pythonhosted.org/packages/d2/27/3d0fc578531820d166e51024e86b8d35feaa828aa961909396f7cce7a191/aiohttp-3.12.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2a81e4ebbc8d9fb6748046577525ada0c5292606ced068ec9ab3aa6d653bf5d9", size = 1716999, upload-time = "2025-05-29T01:35:32.138Z" },
- { url = "https://files.pythonhosted.org/packages/a9/87/1b5466145a55ebf6145eea5e58e5311653946e518e6e04d971acbae81b09/aiohttp-3.12.4-cp312-cp312-win32.whl", hash = "sha256:73cf6ed61849769dce058a6945d7c63da0798e409494c9ca3fddf5b526f7aee4", size = 414443, upload-time = "2025-05-29T01:35:34.07Z" },
- { url = "https://files.pythonhosted.org/packages/70/0c/c11464953fff9c005e700e060b98436960d85bb60104af868bf5ebec6ace/aiohttp-3.12.4-cp312-cp312-win_amd64.whl", hash = "sha256:1e29de2afbe9c777ff8c58900e19654bf435069535a3a182a50256c8cd3eea17", size = 440544, upload-time = "2025-05-29T01:35:35.895Z" },
- { url = "https://files.pythonhosted.org/packages/b3/c5/acc9a65cd92b263050dcc2986e2aee598fc6f3e0b251c9ce7138bf9f387c/aiohttp-3.12.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:789e9ddd591a3161a4e222942e10036d3fb4477464d9a454be2613966b0bce6b", size = 687716, upload-time = "2025-05-29T01:35:37.749Z" },
- { url = "https://files.pythonhosted.org/packages/3b/8b/c36084efb762c8b388e35b564c5c87d287e4d24a77422f7570e36f8195f4/aiohttp-3.12.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8eb37972e6aebe4cab53b0008c4ca7cd412f3f01872f255763ac4bb0ce253d83", size = 465372, upload-time = "2025-05-29T01:35:39.701Z" },
- { url = "https://files.pythonhosted.org/packages/d0/d5/c390226c7f0a2a0e4a7477fb293d311157092231fdb7ab79eb8ad325b3b0/aiohttp-3.12.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ca6af3e929de2c2d3272680437ee5b1e32fa4ac1fb9dfdcc06f5441542d06110", size = 457673, upload-time = "2025-05-29T01:35:42.458Z" },
- { url = "https://files.pythonhosted.org/packages/bc/1a/fdf6ade28154d249b605a6e85f7eb424363618ebcb35f93a7f837fd1f9c9/aiohttp-3.12.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a9b8b482be5c81ceee91fecead2c82b7bec7cfb8b81c0389d6fa4cd82f3bb53", size = 1696485, upload-time = "2025-05-29T01:35:44.489Z" },
- { url = "https://files.pythonhosted.org/packages/71/02/1670b62c82d6e19c77df235b96a56ec055eb40d63b6feff93146544d0224/aiohttp-3.12.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b3f9d7c7486f28cc0fd6bfe5b9accc4ecfe3d4f0471ec53e08aa610e5642dbf3", size = 1677750, upload-time = "2025-05-29T01:35:47.567Z" },
- { url = "https://files.pythonhosted.org/packages/af/eb/75c9863328a9f1f7200ebadf0fefec3a50a2f31e9ccf489faf9c132b87ad/aiohttp-3.12.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42986c6fc949926bcf0928b5440e6adf20b9a14c04dd9ea5e3ba9c7bbd4433a", size = 1729821, upload-time = "2025-05-29T01:35:49.98Z" },
- { url = "https://files.pythonhosted.org/packages/8a/ac/75ef05d10aae033d9bc87d0eea35d904e505c0a7a5d7c7838d1d8b63e954/aiohttp-3.12.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58dded319d52e63ea3c40dbae3f44c1264fa4bb692845b7ff8ce1ddc9319fce3", size = 1779191, upload-time = "2025-05-29T01:35:52.257Z" },
- { url = "https://files.pythonhosted.org/packages/b3/5e/36e5957a073dddb69ed37e5ffa8581548d5d7b9d00daa4ba98fff6c85219/aiohttp-3.12.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1102668bf8c4b744528ef0b5bdaeeb17930832653d1ed9558ab59a0fae91dcf9", size = 1701521, upload-time = "2025-05-29T01:35:54.413Z" },
- { url = "https://files.pythonhosted.org/packages/4e/98/16c3dc7c2534d5109f02da5c88e34e327d8ceddb9b976b4861d787461a59/aiohttp-3.12.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e46c5ad27747416ef0a914da2ad175d9066d8d011960f7b66c9b4f02ef7acfcc", size = 1615227, upload-time = "2025-05-29T01:35:56.595Z" },
- { url = "https://files.pythonhosted.org/packages/74/cb/87eaf79aa41a6bc99c3dd1219caf190f282b5742647bf3abb7b66b7eb221/aiohttp-3.12.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cbcde696c4d4d07b616e10f942e183f90a86ff65e27a03c338067deb1204b148", size = 1668248, upload-time = "2025-05-29T01:36:00.295Z" },
- { url = "https://files.pythonhosted.org/packages/d6/04/2ff57af92f76b0973652710bf9a539d66eb78b4cddace90fc39a5b04bdd7/aiohttp-3.12.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:002e027d4840cb187e5ba6889043e1e90ed114ef8e798133d51db834696a6de2", size = 1699915, upload-time = "2025-05-29T01:36:02.599Z" },
- { url = "https://files.pythonhosted.org/packages/15/d6/0d9916e03cebd697b3c4fc48998733188e8b834368e727b46650a3a1b005/aiohttp-3.12.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cf12c660159897cebdd3ab377550b3563218286f33a57f56753018b1897796ae", size = 1642508, upload-time = "2025-05-29T01:36:05.236Z" },
- { url = "https://files.pythonhosted.org/packages/83/b4/9cf887a3d2cf58828ac6a076d240171d6196dcf7d1edafcb005103f457fb/aiohttp-3.12.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c9e3db6a3c3e53e48b3324eb40e7c5da2a4c78cdcd3ac4e7d7945876dd421de1", size = 1718642, upload-time = "2025-05-29T01:36:07.362Z" },
- { url = "https://files.pythonhosted.org/packages/e5/b0/266567f3c5232e211f1c9bea121a05d115a3f7761c7029ff4ee4f88e6fba/aiohttp-3.12.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e10365dcf61a7c5ed9287c4e20edc0d7a6cc09faf042d7dc570f16ed3291c680", size = 1752113, upload-time = "2025-05-29T01:36:09.519Z" },
- { url = "https://files.pythonhosted.org/packages/61/f9/58b3ce002d1b0b3630ccd02ecbfc6932d00242eb40182e76a65ddbf6ec26/aiohttp-3.12.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c20421e165410bb632f64c5693b1f69e6911dbde197fa0dcd3a0c65d505f776b", size = 1701004, upload-time = "2025-05-29T01:36:11.692Z" },
- { url = "https://files.pythonhosted.org/packages/ee/7c/c1a5e7704fef91f115bd399e47b9613cf11c8caec041a326e966f190c994/aiohttp-3.12.4-cp313-cp313-win32.whl", hash = "sha256:834a2f08eb800af07066af9f26eda4c2d6f7fe0737a3c0aef448f1ba8132fed9", size = 413468, upload-time = "2025-05-29T01:36:13.876Z" },
- { url = "https://files.pythonhosted.org/packages/65/31/e252246332a12abf17f66c8f8360730a5a3a1dd354ca48ccfb90bbb122db/aiohttp-3.12.4-cp313-cp313-win_amd64.whl", hash = "sha256:4c78018c4e8118efac767d5d91c3565919c7e021762c4644198ec5b8d426a071", size = 439411, upload-time = "2025-05-29T01:36:16.365Z" },
- { url = "https://files.pythonhosted.org/packages/b5/3c/91ad3c813948788b28fe7281957b0aea3908cdca6874878edeb492ba107e/aiohttp-3.12.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c98726a164681ed6b864078cdebd84061068f59ff6b32fbcd9ad9371570ba736", size = 697789, upload-time = "2025-05-29T01:36:18.354Z" },
- { url = "https://files.pythonhosted.org/packages/ec/2c/933fdeea9513f0ca06be421cb2a11c6d8d7a47a9cc30856027622b6e3b9a/aiohttp-3.12.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c3ca89bfcfecfd99c75e06571ea538a3b6c455bf5f770133c73b409cfebb2c7e", size = 472770, upload-time = "2025-05-29T01:36:20.337Z" },
- { url = "https://files.pythonhosted.org/packages/59/94/7375b2e7476ac719809fe04006013bdf712c48631d97e019474f85f6904e/aiohttp-3.12.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:832558955ed01cd2bfe21e8df5d12b569826e4032bd809f9901ac530ae291209", size = 460168, upload-time = "2025-05-29T01:36:22.703Z" },
- { url = "https://files.pythonhosted.org/packages/1c/01/81d5ba7d0241e05e358a7d49400e2d2e08bc8bfa6eb5789b212470c80843/aiohttp-3.12.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef9af81531807991d7b8a77812c1ef8c116e529ef051e107a860081ccf764323", size = 1635128, upload-time = "2025-05-29T01:36:24.778Z" },
- { url = "https://files.pythonhosted.org/packages/9a/c5/90f4bfccede5f5edc8e7e3c68c33ddafbe1c28efe6f030e4dcf0a9df0b29/aiohttp-3.12.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce6cf4f19151bbe9f0ea744e18066826bdfa8b99fa1fb4d9da6a92c59bc74685", size = 1609623, upload-time = "2025-05-29T01:36:26.985Z" },
- { url = "https://files.pythonhosted.org/packages/df/6c/3ccab53a384bcd3f1870bb7666b52ccbebfc1c95e347b5e298180730aab9/aiohttp-3.12.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7356ef7ea2074b15057e23c464f201c099ee582f5a7df58360d83c5111b37110", size = 1683251, upload-time = "2025-05-29T01:36:29.197Z" },
- { url = "https://files.pythonhosted.org/packages/ea/0c/9e1ec0a43f4eaf08e6f6d278e734fbe62513f5d504ea255acdafbefbe35e/aiohttp-3.12.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1914c9a1cccc2b66254266856e33142e7ca52d17e539d104b5b40191978cd9ab", size = 1722563, upload-time = "2025-05-29T01:36:31.557Z" },
- { url = "https://files.pythonhosted.org/packages/59/15/873f98e14fa51bcdd600cb16f05946ff6d20ad0ec353fffe986a27e35506/aiohttp-3.12.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a696cf379c9d2ed7e79fa6b064e0737c13545d40adbc93deab33a1c473f6f8b0", size = 1628805, upload-time = "2025-05-29T01:36:33.795Z" },
- { url = "https://files.pythonhosted.org/packages/90/4a/38670ec28dfc500d3c5ee23db56435cfd5b492fc4741456216fc54d9ad33/aiohttp-3.12.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bf501e5bd6598eaa3db3d177bd4a83d4bddfea5f36e686fa03b75aff2c7dc795", size = 1563806, upload-time = "2025-05-29T01:36:36.058Z" },
- { url = "https://files.pythonhosted.org/packages/89/ff/c2abcde85b1a32819f567e858dd9b79f90e157bfc6fd7eba9050b1906559/aiohttp-3.12.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:44b9c8eeddda6b154b7add82a039fbb75e15ad424e2eaea76a9ff3e563a8f28d", size = 1611765, upload-time = "2025-05-29T01:36:38.271Z" },
- { url = "https://files.pythonhosted.org/packages/5d/07/2241314968218d48029cb26c41c71f54bfc420d2ea0c3d71e6e2c0fdcccc/aiohttp-3.12.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:40aa98c42a104a8863d78e26442d8db29babe1b42ff234cd278a0385b15d6faf", size = 1621905, upload-time = "2025-05-29T01:36:40.998Z" },
- { url = "https://files.pythonhosted.org/packages/f5/70/c012909236f212ace2a1f11baa041b66ce41d404c238e3bbce9c6416c13f/aiohttp-3.12.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:df4c6a6d2a9807acead6355dbe99f038785dafd0f183d824f40531fce1ad9cb7", size = 1598550, upload-time = "2025-05-29T01:36:43.304Z" },
- { url = "https://files.pythonhosted.org/packages/d1/08/d789e51371fdf8ce85d1be601e12371b46571c50e71c185a83f9d33e9b72/aiohttp-3.12.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:8917cda033994515b82f676484a2245cccecc7010afeb77c45401862f0957cbf", size = 1686148, upload-time = "2025-05-29T01:36:45.512Z" },
- { url = "https://files.pythonhosted.org/packages/c6/b1/2d298d1f09eb94ce3d574e3a52aedaa8f45248ac9143f57bba7dab1d901f/aiohttp-3.12.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4e1083608e3ca5d8798ad5b54a85c40074d4453deb454d574e42f6f694b3fb55", size = 1701824, upload-time = "2025-05-29T01:36:47.779Z" },
- { url = "https://files.pythonhosted.org/packages/79/75/af3269a294c7b9cf0f423f9a131823512a6124157d30affddf37ecdb45e5/aiohttp-3.12.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9f9181653d3761ba949696df027d488d5267df50fe88a8960b0c4bb82ab39741", size = 1631492, upload-time = "2025-05-29T01:36:50.039Z" },
- { url = "https://files.pythonhosted.org/packages/09/70/b8f928f2a2692527b1d8c81ff7521a8d6d42ab25bfa24de6750110a59f2c/aiohttp-3.12.4-cp39-cp39-win32.whl", hash = "sha256:b3547d8388bc4a8d890e432d0a7a9943f3bb1ee3a0083447323efe5f0646d83a", size = 421042, upload-time = "2025-05-29T01:36:52.427Z" },
- { url = "https://files.pythonhosted.org/packages/a3/8b/8d1ca815a0725b66dfc094796c660281ec91ca31e37c788311ecba9bb128/aiohttp-3.12.4-cp39-cp39-win_amd64.whl", hash = "sha256:592086c0ed4fc071fecf097c54acebfac725376a0bdbbd1be31f1cc23cbf84c5", size = 444323, upload-time = "2025-05-29T01:36:54.667Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/1c/ce/3b83ebba6b3207a7135e5fcaba49706f8a4b6008153b4e30540c982fae26/aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", size = 7837994, upload-time = "2025-10-28T20:59:39.937Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6d/34/939730e66b716b76046dedfe0842995842fa906ccc4964bba414ff69e429/aiohttp-3.13.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2372b15a5f62ed37789a6b383ff7344fc5b9f243999b0cd9b629d8bc5f5b4155", size = 736471, upload-time = "2025-10-28T20:55:27.924Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/cf/dcbdf2df7f6ca72b0bb4c0b4509701f2d8942cf54e29ca197389c214c07f/aiohttp-3.13.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e7f8659a48995edee7229522984bd1009c1213929c769c2daa80b40fe49a180c", size = 493985, upload-time = "2025-10-28T20:55:29.456Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/87/71c8867e0a1d0882dcbc94af767784c3cb381c1c4db0943ab4aae4fed65e/aiohttp-3.13.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:939ced4a7add92296b0ad38892ce62b98c619288a081170695c6babe4f50e636", size = 489274, upload-time = "2025-10-28T20:55:31.134Z" },
+ { url = "https://files.pythonhosted.org/packages/38/0f/46c24e8dae237295eaadd113edd56dee96ef6462adf19b88592d44891dc5/aiohttp-3.13.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6315fb6977f1d0dd41a107c527fee2ed5ab0550b7d885bc15fee20ccb17891da", size = 1668171, upload-time = "2025-10-28T20:55:36.065Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/c6/4cdfb4440d0e28483681a48f69841fa5e39366347d66ef808cbdadddb20e/aiohttp-3.13.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6e7352512f763f760baaed2637055c49134fd1d35b37c2dedfac35bfe5cf8725", size = 1636036, upload-time = "2025-10-28T20:55:37.576Z" },
+ { url = "https://files.pythonhosted.org/packages/84/37/8708cf678628216fb678ab327a4e1711c576d6673998f4f43e86e9ae90dd/aiohttp-3.13.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e09a0a06348a2dd73e7213353c90d709502d9786219f69b731f6caa0efeb46f5", size = 1727975, upload-time = "2025-10-28T20:55:39.457Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/2e/3ebfe12fdcb9b5f66e8a0a42dffcd7636844c8a018f261efb2419f68220b/aiohttp-3.13.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a09a6d073fb5789456545bdee2474d14395792faa0527887f2f4ec1a486a59d3", size = 1815823, upload-time = "2025-10-28T20:55:40.958Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/4f/ca2ef819488cbb41844c6cf92ca6dd15b9441e6207c58e5ae0e0fc8d70ad/aiohttp-3.13.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b59d13c443f8e049d9e94099c7e412e34610f1f49be0f230ec656a10692a5802", size = 1669374, upload-time = "2025-10-28T20:55:42.745Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/fe/1fe2e1179a0d91ce09c99069684aab619bf2ccde9b20bd6ca44f8837203e/aiohttp-3.13.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:20db2d67985d71ca033443a1ba2001c4b5693fe09b0e29f6d9358a99d4d62a8a", size = 1555315, upload-time = "2025-10-28T20:55:44.264Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/2b/f3781899b81c45d7cbc7140cddb8a3481c195e7cbff8e36374759d2ab5a5/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:960c2fc686ba27b535f9fd2b52d87ecd7e4fd1cf877f6a5cba8afb5b4a8bd204", size = 1639140, upload-time = "2025-10-28T20:55:46.626Z" },
+ { url = "https://files.pythonhosted.org/packages/72/27/c37e85cd3ece6f6c772e549bd5a253d0c122557b25855fb274224811e4f2/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6c00dbcf5f0d88796151e264a8eab23de2997c9303dd7c0bf622e23b24d3ce22", size = 1645496, upload-time = "2025-10-28T20:55:48.933Z" },
+ { url = "https://files.pythonhosted.org/packages/66/20/3af1ab663151bd3780b123e907761cdb86ec2c4e44b2d9b195ebc91fbe37/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fed38a5edb7945f4d1bcabe2fcd05db4f6ec7e0e82560088b754f7e08d93772d", size = 1697625, upload-time = "2025-10-28T20:55:50.377Z" },
+ { url = "https://files.pythonhosted.org/packages/95/eb/ae5cab15efa365e13d56b31b0d085a62600298bf398a7986f8388f73b598/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:b395bbca716c38bef3c764f187860e88c724b342c26275bc03e906142fc5964f", size = 1542025, upload-time = "2025-10-28T20:55:51.861Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/2d/1683e8d67ec72d911397fe4e575688d2a9b8f6a6e03c8fdc9f3fd3d4c03f/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:204ffff2426c25dfda401ba08da85f9c59525cdc42bda26660463dd1cbcfec6f", size = 1714918, upload-time = "2025-10-28T20:55:53.515Z" },
+ { url = "https://files.pythonhosted.org/packages/99/a2/ffe8e0e1c57c5e542d47ffa1fcf95ef2b3ea573bf7c4d2ee877252431efc/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:05c4dd3c48fb5f15db31f57eb35374cb0c09afdde532e7fb70a75aede0ed30f6", size = 1656113, upload-time = "2025-10-28T20:55:55.438Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/42/d511aff5c3a2b06c09d7d214f508a4ad8ac7799817f7c3d23e7336b5e896/aiohttp-3.13.2-cp310-cp310-win32.whl", hash = "sha256:e574a7d61cf10351d734bcddabbe15ede0eaa8a02070d85446875dc11189a251", size = 432290, upload-time = "2025-10-28T20:55:56.96Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/ea/1c2eb7098b5bad4532994f2b7a8228d27674035c9b3234fe02c37469ef14/aiohttp-3.13.2-cp310-cp310-win_amd64.whl", hash = "sha256:364f55663085d658b8462a1c3f17b2b84a5c2e1ba858e1b79bff7b2e24ad1514", size = 455075, upload-time = "2025-10-28T20:55:58.373Z" },
+ { url = "https://files.pythonhosted.org/packages/35/74/b321e7d7ca762638cdf8cdeceb39755d9c745aff7a64c8789be96ddf6e96/aiohttp-3.13.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4647d02df098f6434bafd7f32ad14942f05a9caa06c7016fdcc816f343997dd0", size = 743409, upload-time = "2025-10-28T20:56:00.354Z" },
+ { url = "https://files.pythonhosted.org/packages/99/3d/91524b905ec473beaf35158d17f82ef5a38033e5809fe8742e3657cdbb97/aiohttp-3.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e3403f24bcb9c3b29113611c3c16a2a447c3953ecf86b79775e7be06f7ae7ccb", size = 497006, upload-time = "2025-10-28T20:56:01.85Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d3/7f68bc02a67716fe80f063e19adbd80a642e30682ce74071269e17d2dba1/aiohttp-3.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:43dff14e35aba17e3d6d5ba628858fb8cb51e30f44724a2d2f0c75be492c55e9", size = 493195, upload-time = "2025-10-28T20:56:03.314Z" },
+ { url = "https://files.pythonhosted.org/packages/98/31/913f774a4708775433b7375c4f867d58ba58ead833af96c8af3621a0d243/aiohttp-3.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2a9ea08e8c58bb17655630198833109227dea914cd20be660f52215f6de5613", size = 1747759, upload-time = "2025-10-28T20:56:04.904Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/63/04efe156f4326f31c7c4a97144f82132c3bb21859b7bb84748d452ccc17c/aiohttp-3.13.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53b07472f235eb80e826ad038c9d106c2f653584753f3ddab907c83f49eedead", size = 1704456, upload-time = "2025-10-28T20:56:06.986Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/02/4e16154d8e0a9cf4ae76f692941fd52543bbb148f02f098ca73cab9b1c1b/aiohttp-3.13.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e736c93e9c274fce6419af4aac199984d866e55f8a4cec9114671d0ea9688780", size = 1807572, upload-time = "2025-10-28T20:56:08.558Z" },
+ { url = "https://files.pythonhosted.org/packages/34/58/b0583defb38689e7f06798f0285b1ffb3a6fb371f38363ce5fd772112724/aiohttp-3.13.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ff5e771f5dcbc81c64898c597a434f7682f2259e0cd666932a913d53d1341d1a", size = 1895954, upload-time = "2025-10-28T20:56:10.545Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/f3/083907ee3437425b4e376aa58b2c915eb1a33703ec0dc30040f7ae3368c6/aiohttp-3.13.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3b6fb0c207cc661fa0bf8c66d8d9b657331ccc814f4719468af61034b478592", size = 1747092, upload-time = "2025-10-28T20:56:12.118Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/61/98a47319b4e425cc134e05e5f3fc512bf9a04bf65aafd9fdcda5d57ec693/aiohttp-3.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:97a0895a8e840ab3520e2288db7cace3a1981300d48babeb50e7425609e2e0ab", size = 1606815, upload-time = "2025-10-28T20:56:14.191Z" },
+ { url = "https://files.pythonhosted.org/packages/97/4b/e78b854d82f66bb974189135d31fce265dee0f5344f64dd0d345158a5973/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9e8f8afb552297aca127c90cb840e9a1d4bfd6a10d7d8f2d9176e1acc69bad30", size = 1723789, upload-time = "2025-10-28T20:56:16.101Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/fc/9d2ccc794fc9b9acd1379d625c3a8c64a45508b5091c546dea273a41929e/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ed2f9c7216e53c3df02264f25d824b079cc5914f9e2deba94155190ef648ee40", size = 1718104, upload-time = "2025-10-28T20:56:17.655Z" },
+ { url = "https://files.pythonhosted.org/packages/66/65/34564b8765ea5c7d79d23c9113135d1dd3609173da13084830f1507d56cf/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:99c5280a329d5fa18ef30fd10c793a190d996567667908bef8a7f81f8202b948", size = 1785584, upload-time = "2025-10-28T20:56:19.238Z" },
+ { url = "https://files.pythonhosted.org/packages/30/be/f6a7a426e02fc82781afd62016417b3948e2207426d90a0e478790d1c8a4/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ca6ffef405fc9c09a746cb5d019c1672cd7f402542e379afc66b370833170cf", size = 1595126, upload-time = "2025-10-28T20:56:20.836Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/c7/8e22d5d28f94f67d2af496f14a83b3c155d915d1fe53d94b66d425ec5b42/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:47f438b1a28e926c37632bff3c44df7d27c9b57aaf4e34b1def3c07111fdb782", size = 1800665, upload-time = "2025-10-28T20:56:22.922Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/91133c8b68b1da9fc16555706aa7276fdf781ae2bb0876c838dd86b8116e/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9acda8604a57bb60544e4646a4615c1866ee6c04a8edef9b8ee6fd1d8fa2ddc8", size = 1739532, upload-time = "2025-10-28T20:56:25.924Z" },
+ { url = "https://files.pythonhosted.org/packages/17/6b/3747644d26a998774b21a616016620293ddefa4d63af6286f389aedac844/aiohttp-3.13.2-cp311-cp311-win32.whl", hash = "sha256:868e195e39b24aaa930b063c08bb0c17924899c16c672a28a65afded9c46c6ec", size = 431876, upload-time = "2025-10-28T20:56:27.524Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/63/688462108c1a00eb9f05765331c107f95ae86f6b197b865d29e930b7e462/aiohttp-3.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:7fd19df530c292542636c2a9a85854fab93474396a52f1695e799186bbd7f24c", size = 456205, upload-time = "2025-10-28T20:56:29.062Z" },
+ { url = "https://files.pythonhosted.org/packages/29/9b/01f00e9856d0a73260e86dd8ed0c2234a466c5c1712ce1c281548df39777/aiohttp-3.13.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b1e56bab2e12b2b9ed300218c351ee2a3d8c8fdab5b1ec6193e11a817767e47b", size = 737623, upload-time = "2025-10-28T20:56:30.797Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/1b/4be39c445e2b2bd0aab4ba736deb649fabf14f6757f405f0c9685019b9e9/aiohttp-3.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:364e25edaabd3d37b1db1f0cbcee8c73c9a3727bfa262b83e5e4cf3489a2a9dc", size = 492664, upload-time = "2025-10-28T20:56:32.708Z" },
+ { url = "https://files.pythonhosted.org/packages/28/66/d35dcfea8050e131cdd731dff36434390479b4045a8d0b9d7111b0a968f1/aiohttp-3.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c5c94825f744694c4b8db20b71dba9a257cd2ba8e010a803042123f3a25d50d7", size = 491808, upload-time = "2025-10-28T20:56:34.57Z" },
+ { url = "https://files.pythonhosted.org/packages/00/29/8e4609b93e10a853b65f8291e64985de66d4f5848c5637cddc70e98f01f8/aiohttp-3.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba2715d842ffa787be87cbfce150d5e88c87a98e0b62e0f5aa489169a393dbbb", size = 1738863, upload-time = "2025-10-28T20:56:36.377Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/fa/4ebdf4adcc0def75ced1a0d2d227577cd7b1b85beb7edad85fcc87693c75/aiohttp-3.13.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:585542825c4bc662221fb257889e011a5aa00f1ae4d75d1d246a5225289183e3", size = 1700586, upload-time = "2025-10-28T20:56:38.034Z" },
+ { url = "https://files.pythonhosted.org/packages/da/04/73f5f02ff348a3558763ff6abe99c223381b0bace05cd4530a0258e52597/aiohttp-3.13.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39d02cb6025fe1aabca329c5632f48c9532a3dabccd859e7e2f110668972331f", size = 1768625, upload-time = "2025-10-28T20:56:39.75Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/49/a825b79ffec124317265ca7d2344a86bcffeb960743487cb11988ffb3494/aiohttp-3.13.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e67446b19e014d37342f7195f592a2a948141d15a312fe0e700c2fd2f03124f6", size = 1867281, upload-time = "2025-10-28T20:56:41.471Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/48/adf56e05f81eac31edcfae45c90928f4ad50ef2e3ea72cb8376162a368f8/aiohttp-3.13.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4356474ad6333e41ccefd39eae869ba15a6c5299c9c01dfdcfdd5c107be4363e", size = 1752431, upload-time = "2025-10-28T20:56:43.162Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ab/593855356eead019a74e862f21523db09c27f12fd24af72dbc3555b9bfd9/aiohttp-3.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeacf451c99b4525f700f078becff32c32ec327b10dcf31306a8a52d78166de7", size = 1562846, upload-time = "2025-10-28T20:56:44.85Z" },
+ { url = "https://files.pythonhosted.org/packages/39/0f/9f3d32271aa8dc35036e9668e31870a9d3b9542dd6b3e2c8a30931cb27ae/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8a9b889aeabd7a4e9af0b7f4ab5ad94d42e7ff679aaec6d0db21e3b639ad58d", size = 1699606, upload-time = "2025-10-28T20:56:46.519Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/3c/52d2658c5699b6ef7692a3f7128b2d2d4d9775f2a68093f74bca06cf01e1/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fa89cb11bc71a63b69568d5b8a25c3ca25b6d54c15f907ca1c130d72f320b76b", size = 1720663, upload-time = "2025-10-28T20:56:48.528Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/d4/8f8f3ff1fb7fb9e3f04fcad4e89d8a1cd8fc7d05de67e3de5b15b33008ff/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8aa7c807df234f693fed0ecd507192fc97692e61fee5702cdc11155d2e5cadc8", size = 1737939, upload-time = "2025-10-28T20:56:50.77Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d3/ddd348f8a27a634daae39a1b8e291ff19c77867af438af844bf8b7e3231b/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9eb3e33fdbe43f88c3c75fa608c25e7c47bbd80f48d012763cb67c47f39a7e16", size = 1555132, upload-time = "2025-10-28T20:56:52.568Z" },
+ { url = "https://files.pythonhosted.org/packages/39/b8/46790692dc46218406f94374903ba47552f2f9f90dad554eed61bfb7b64c/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9434bc0d80076138ea986833156c5a48c9c7a8abb0c96039ddbb4afc93184169", size = 1764802, upload-time = "2025-10-28T20:56:54.292Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/e4/19ce547b58ab2a385e5f0b8aa3db38674785085abcf79b6e0edd1632b12f/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff15c147b2ad66da1f2cbb0622313f2242d8e6e8f9b79b5206c84523a4473248", size = 1719512, upload-time = "2025-10-28T20:56:56.428Z" },
+ { url = "https://files.pythonhosted.org/packages/70/30/6355a737fed29dcb6dfdd48682d5790cb5eab050f7b4e01f49b121d3acad/aiohttp-3.13.2-cp312-cp312-win32.whl", hash = "sha256:27e569eb9d9e95dbd55c0fc3ec3a9335defbf1d8bc1d20171a49f3c4c607b93e", size = 426690, upload-time = "2025-10-28T20:56:58.736Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/0d/b10ac09069973d112de6ef980c1f6bb31cb7dcd0bc363acbdad58f927873/aiohttp-3.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:8709a0f05d59a71f33fd05c17fc11fcb8c30140506e13c2f5e8ee1b8964e1b45", size = 453465, upload-time = "2025-10-28T20:57:00.795Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/78/7e90ca79e5aa39f9694dcfd74f4720782d3c6828113bb1f3197f7e7c4a56/aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be", size = 732139, upload-time = "2025-10-28T20:57:02.455Z" },
+ { url = "https://files.pythonhosted.org/packages/db/ed/1f59215ab6853fbaa5c8495fa6cbc39edfc93553426152b75d82a5f32b76/aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742", size = 490082, upload-time = "2025-10-28T20:57:04.784Z" },
+ { url = "https://files.pythonhosted.org/packages/68/7b/fe0fe0f5e05e13629d893c760465173a15ad0039c0a5b0d0040995c8075e/aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293", size = 489035, upload-time = "2025-10-28T20:57:06.894Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/04/db5279e38471b7ac801d7d36a57d1230feeee130bbe2a74f72731b23c2b1/aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811", size = 1720387, upload-time = "2025-10-28T20:57:08.685Z" },
+ { url = "https://files.pythonhosted.org/packages/31/07/8ea4326bd7dae2bd59828f69d7fdc6e04523caa55e4a70f4a8725a7e4ed2/aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a", size = 1688314, upload-time = "2025-10-28T20:57:10.693Z" },
+ { url = "https://files.pythonhosted.org/packages/48/ab/3d98007b5b87ffd519d065225438cc3b668b2f245572a8cb53da5dd2b1bc/aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4", size = 1756317, upload-time = "2025-10-28T20:57:12.563Z" },
+ { url = "https://files.pythonhosted.org/packages/97/3d/801ca172b3d857fafb7b50c7c03f91b72b867a13abca982ed6b3081774ef/aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a", size = 1858539, upload-time = "2025-10-28T20:57:14.623Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/0d/4764669bdf47bd472899b3d3db91fffbe925c8e3038ec591a2fd2ad6a14d/aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e", size = 1739597, upload-time = "2025-10-28T20:57:16.399Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/52/7bd3c6693da58ba16e657eb904a5b6decfc48ecd06e9ac098591653b1566/aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb", size = 1555006, upload-time = "2025-10-28T20:57:18.288Z" },
+ { url = "https://files.pythonhosted.org/packages/48/30/9586667acec5993b6f41d2ebcf96e97a1255a85f62f3c653110a5de4d346/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded", size = 1683220, upload-time = "2025-10-28T20:57:20.241Z" },
+ { url = "https://files.pythonhosted.org/packages/71/01/3afe4c96854cfd7b30d78333852e8e851dceaec1c40fd00fec90c6402dd2/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b", size = 1712570, upload-time = "2025-10-28T20:57:22.253Z" },
+ { url = "https://files.pythonhosted.org/packages/11/2c/22799d8e720f4697a9e66fd9c02479e40a49de3de2f0bbe7f9f78a987808/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8", size = 1733407, upload-time = "2025-10-28T20:57:24.37Z" },
+ { url = "https://files.pythonhosted.org/packages/34/cb/90f15dd029f07cebbd91f8238a8b363978b530cd128488085b5703683594/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04", size = 1550093, upload-time = "2025-10-28T20:57:26.257Z" },
+ { url = "https://files.pythonhosted.org/packages/69/46/12dce9be9d3303ecbf4d30ad45a7683dc63d90733c2d9fe512be6716cd40/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476", size = 1758084, upload-time = "2025-10-28T20:57:28.349Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/c8/0932b558da0c302ffd639fc6362a313b98fdf235dc417bc2493da8394df7/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23", size = 1716987, upload-time = "2025-10-28T20:57:30.233Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/8b/f5bd1a75003daed099baec373aed678f2e9b34f2ad40d85baa1368556396/aiohttp-3.13.2-cp313-cp313-win32.whl", hash = "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254", size = 425859, upload-time = "2025-10-28T20:57:32.105Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/28/a8a9fc6957b2cee8902414e41816b5ab5536ecf43c3b1843c10e82c559b2/aiohttp-3.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a", size = 452192, upload-time = "2025-10-28T20:57:34.166Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/36/e2abae1bd815f01c957cbf7be817b3043304e1c87bad526292a0410fdcf9/aiohttp-3.13.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2475391c29230e063ef53a66669b7b691c9bfc3f1426a0f7bcdf1216bdbac38b", size = 735234, upload-time = "2025-10-28T20:57:36.415Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/e3/1ee62dde9b335e4ed41db6bba02613295a0d5b41f74a783c142745a12763/aiohttp-3.13.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f33c8748abef4d8717bb20e8fb1b3e07c6adacb7fd6beaae971a764cf5f30d61", size = 490733, upload-time = "2025-10-28T20:57:38.205Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/aa/7a451b1d6a04e8d15a362af3e9b897de71d86feac3babf8894545d08d537/aiohttp-3.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae32f24bbfb7dbb485a24b30b1149e2f200be94777232aeadba3eecece4d0aa4", size = 491303, upload-time = "2025-10-28T20:57:40.122Z" },
+ { url = "https://files.pythonhosted.org/packages/57/1e/209958dbb9b01174870f6a7538cd1f3f28274fdbc88a750c238e2c456295/aiohttp-3.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7f02042c1f009ffb70067326ef183a047425bb2ff3bc434ead4dd4a4a66a2b", size = 1717965, upload-time = "2025-10-28T20:57:42.28Z" },
+ { url = "https://files.pythonhosted.org/packages/08/aa/6a01848d6432f241416bc4866cae8dc03f05a5a884d2311280f6a09c73d6/aiohttp-3.13.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93655083005d71cd6c072cdab54c886e6570ad2c4592139c3fb967bfc19e4694", size = 1667221, upload-time = "2025-10-28T20:57:44.869Z" },
+ { url = "https://files.pythonhosted.org/packages/87/4f/36c1992432d31bbc789fa0b93c768d2e9047ec8c7177e5cd84ea85155f36/aiohttp-3.13.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0db1e24b852f5f664cd728db140cf11ea0e82450471232a394b3d1a540b0f906", size = 1757178, upload-time = "2025-10-28T20:57:47.216Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/b4/8e940dfb03b7e0f68a82b88fd182b9be0a65cb3f35612fe38c038c3112cf/aiohttp-3.13.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b009194665bcd128e23eaddef362e745601afa4641930848af4c8559e88f18f9", size = 1838001, upload-time = "2025-10-28T20:57:49.337Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ef/39f3448795499c440ab66084a9db7d20ca7662e94305f175a80f5b7e0072/aiohttp-3.13.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c038a8fdc8103cd51dbd986ecdce141473ffd9775a7a8057a6ed9c3653478011", size = 1716325, upload-time = "2025-10-28T20:57:51.327Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/51/b311500ffc860b181c05d91c59a1313bdd05c82960fdd4035a15740d431e/aiohttp-3.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66bac29b95a00db411cd758fea0e4b9bdba6d549dfe333f9a945430f5f2cc5a6", size = 1547978, upload-time = "2025-10-28T20:57:53.554Z" },
+ { url = "https://files.pythonhosted.org/packages/31/64/b9d733296ef79815226dab8c586ff9e3df41c6aff2e16c06697b2d2e6775/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4ebf9cfc9ba24a74cf0718f04aac2a3bbe745902cc7c5ebc55c0f3b5777ef213", size = 1682042, upload-time = "2025-10-28T20:57:55.617Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/30/43d3e0f9d6473a6db7d472104c4eff4417b1e9df01774cb930338806d36b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a4b88ebe35ce54205c7074f7302bd08a4cb83256a3e0870c72d6f68a3aaf8e49", size = 1680085, upload-time = "2025-10-28T20:57:57.59Z" },
+ { url = "https://files.pythonhosted.org/packages/16/51/c709f352c911b1864cfd1087577760ced64b3e5bee2aa88b8c0c8e2e4972/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:98c4fb90bb82b70a4ed79ca35f656f4281885be076f3f970ce315402b53099ae", size = 1728238, upload-time = "2025-10-28T20:57:59.525Z" },
+ { url = "https://files.pythonhosted.org/packages/19/e2/19bd4c547092b773caeb48ff5ae4b1ae86756a0ee76c16727fcfd281404b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ec7534e63ae0f3759df3a1ed4fa6bc8f75082a924b590619c0dd2f76d7043caa", size = 1544395, upload-time = "2025-10-28T20:58:01.914Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/87/860f2803b27dfc5ed7be532832a3498e4919da61299b4a1f8eb89b8ff44d/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5b927cf9b935a13e33644cbed6c8c4b2d0f25b713d838743f8fe7191b33829c4", size = 1742965, upload-time = "2025-10-28T20:58:03.972Z" },
+ { url = "https://files.pythonhosted.org/packages/67/7f/db2fc7618925e8c7a601094d5cbe539f732df4fb570740be88ed9e40e99a/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:88d6c017966a78c5265d996c19cdb79235be5e6412268d7e2ce7dee339471b7a", size = 1697585, upload-time = "2025-10-28T20:58:06.189Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/07/9127916cb09bb38284db5036036042b7b2c514c8ebaeee79da550c43a6d6/aiohttp-3.13.2-cp314-cp314-win32.whl", hash = "sha256:f7c183e786e299b5d6c49fb43a769f8eb8e04a2726a2bd5887b98b5cc2d67940", size = 431621, upload-time = "2025-10-28T20:58:08.636Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/41/554a8a380df6d3a2bba8a7726429a23f4ac62aaf38de43bb6d6cde7b4d4d/aiohttp-3.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe242cd381e0fb65758faf5ad96c2e460df6ee5b2de1072fe97e4127927e00b4", size = 457627, upload-time = "2025-10-28T20:58:11Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/8e/3824ef98c039d3951cb65b9205a96dd2b20f22241ee17d89c5701557c826/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f10d9c0b0188fe85398c61147bbd2a657d616c876863bfeff43376e0e3134673", size = 767360, upload-time = "2025-10-28T20:58:13.358Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/0f/6a03e3fc7595421274fa34122c973bde2d89344f8a881b728fa8c774e4f1/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e7c952aefdf2460f4ae55c5e9c3e80aa72f706a6317e06020f80e96253b1accd", size = 504616, upload-time = "2025-10-28T20:58:15.339Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/aa/ed341b670f1bc8a6f2c6a718353d13b9546e2cef3544f573c6a1ff0da711/aiohttp-3.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20423ce14771d98353d2e25e83591fa75dfa90a3c1848f3d7c68243b4fbded3", size = 509131, upload-time = "2025-10-28T20:58:17.693Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/f0/c68dac234189dae5c4bbccc0f96ce0cc16b76632cfc3a08fff180045cfa4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e96eb1a34396e9430c19d8338d2ec33015e4a87ef2b4449db94c22412e25ccdf", size = 1864168, upload-time = "2025-10-28T20:58:20.113Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/65/75a9a76db8364b5d0e52a0c20eabc5d52297385d9af9c35335b924fafdee/aiohttp-3.13.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23fb0783bc1a33640036465019d3bba069942616a6a2353c6907d7fe1ccdaf4e", size = 1719200, upload-time = "2025-10-28T20:58:22.583Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/55/8df2ed78d7f41d232f6bd3ff866b6f617026551aa1d07e2f03458f964575/aiohttp-3.13.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1a9bea6244a1d05a4e57c295d69e159a5c50d8ef16aa390948ee873478d9a5", size = 1843497, upload-time = "2025-10-28T20:58:24.672Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/e0/94d7215e405c5a02ccb6a35c7a3a6cfff242f457a00196496935f700cde5/aiohttp-3.13.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a3d54e822688b56e9f6b5816fb3de3a3a64660efac64e4c2dc435230ad23bad", size = 1935703, upload-time = "2025-10-28T20:58:26.758Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/78/1eeb63c3f9b2d1015a4c02788fb543141aad0a03ae3f7a7b669b2483f8d4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a653d872afe9f33497215745da7a943d1dc15b728a9c8da1c3ac423af35178e", size = 1792738, upload-time = "2025-10-28T20:58:29.787Z" },
+ { url = "https://files.pythonhosted.org/packages/41/75/aaf1eea4c188e51538c04cc568040e3082db263a57086ea74a7d38c39e42/aiohttp-3.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56d36e80d2003fa3fc0207fac644216d8532e9504a785ef9a8fd013f84a42c61", size = 1624061, upload-time = "2025-10-28T20:58:32.529Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/c2/3b6034de81fbcc43de8aeb209073a2286dfb50b86e927b4efd81cf848197/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:78cd586d8331fb8e241c2dd6b2f4061778cc69e150514b39a9e28dd050475661", size = 1789201, upload-time = "2025-10-28T20:58:34.618Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/38/c15dcf6d4d890217dae79d7213988f4e5fe6183d43893a9cf2fe9e84ca8d/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:20b10bbfbff766294fe99987f7bb3b74fdd2f1a2905f2562132641ad434dcf98", size = 1776868, upload-time = "2025-10-28T20:58:38.835Z" },
+ { url = "https://files.pythonhosted.org/packages/04/75/f74fd178ac81adf4f283a74847807ade5150e48feda6aef024403716c30c/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9ec49dff7e2b3c85cdeaa412e9d438f0ecd71676fde61ec57027dd392f00c693", size = 1790660, upload-time = "2025-10-28T20:58:41.507Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/80/7368bd0d06b16b3aba358c16b919e9c46cf11587dc572091031b0e9e3ef0/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:94f05348c4406450f9d73d38efb41d669ad6cd90c7ee194810d0eefbfa875a7a", size = 1617548, upload-time = "2025-10-28T20:58:43.674Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/4b/a6212790c50483cb3212e507378fbe26b5086d73941e1ec4b56a30439688/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:fa4dcb605c6f82a80c7f95713c2b11c3b8e9893b3ebd2bc9bde93165ed6107be", size = 1817240, upload-time = "2025-10-28T20:58:45.787Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/f7/ba5f0ba4ea8d8f3c32850912944532b933acbf0f3a75546b89269b9b7dde/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf00e5db968c3f67eccd2778574cf64d8b27d95b237770aa32400bd7a1ca4f6c", size = 1762334, upload-time = "2025-10-28T20:58:47.936Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/83/1a5a1856574588b1cad63609ea9ad75b32a8353ac995d830bf5da9357364/aiohttp-3.13.2-cp314-cp314t-win32.whl", hash = "sha256:d23b5fe492b0805a50d3371e8a728a9134d8de5447dce4c885f5587294750734", size = 464685, upload-time = "2025-10-28T20:58:50.642Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/4d/d22668674122c08f4d56972297c51a624e64b3ed1efaa40187607a7cb66e/aiohttp-3.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f", size = 498093, upload-time = "2025-10-28T20:58:52.782Z" },
+ { url = "https://files.pythonhosted.org/packages/04/4a/3da532fdf51b5e58fffa1a86d6569184cb1bf4bf81cd4434b6541a8d14fd/aiohttp-3.13.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7fbdf5ad6084f1940ce88933de34b62358d0f4a0b6ec097362dcd3e5a65a4989", size = 739009, upload-time = "2025-10-28T20:58:55.682Z" },
+ { url = "https://files.pythonhosted.org/packages/89/74/fefa6f7939cdc1d77e5cad712004e675a8847dccc589dcc3abca7feaed73/aiohttp-3.13.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7c3a50345635a02db61792c85bb86daffac05330f6473d524f1a4e3ef9d0046d", size = 495308, upload-time = "2025-10-28T20:58:58.408Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/b4/a0638ae1f12d09a0dc558870968a2f19a1eba1b10ad0a85ef142ddb40b50/aiohttp-3.13.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0e87dff73f46e969af38ab3f7cb75316a7c944e2e574ff7c933bc01b10def7f5", size = 490624, upload-time = "2025-10-28T20:59:00.479Z" },
+ { url = "https://files.pythonhosted.org/packages/02/73/361cd4cac9d98a5a4183d1f26faf7b777330f8dba838c5aae2412862bdd0/aiohttp-3.13.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2adebd4577724dcae085665f294cc57c8701ddd4d26140504db622b8d566d7aa", size = 1662968, upload-time = "2025-10-28T20:59:03.105Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/93/ce2ca7584555a6c7dd78f2e6b539a96c5172d88815e13a05a576e14a5a22/aiohttp-3.13.2-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e036a3a645fe92309ec34b918394bb377950cbb43039a97edae6c08db64b23e2", size = 1627117, upload-time = "2025-10-28T20:59:05.274Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/42/7ee0e699111f5fc20a69b3203e8f5d5da0b681f270b90bc088d15e339980/aiohttp-3.13.2-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:23ad365e30108c422d0b4428cf271156dd56790f6dd50d770b8e360e6c5ab2e6", size = 1724037, upload-time = "2025-10-28T20:59:07.522Z" },
+ { url = "https://files.pythonhosted.org/packages/66/88/67ad5ff11dd61dd1d7882cda39f085d5fca31cf7e2143f5173429d8a591e/aiohttp-3.13.2-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f9b2c2d4b9d958b1f9ae0c984ec1dd6b6689e15c75045be8ccb4011426268ca", size = 1812899, upload-time = "2025-10-28T20:59:11.698Z" },
+ { url = "https://files.pythonhosted.org/packages/60/1b/a46f6e1c2a347b9c7a789292279c159b327fadecbf8340f3b05fffff1151/aiohttp-3.13.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a92cf4b9bea33e15ecbaa5c59921be0f23222608143d025c989924f7e3e0c07", size = 1660961, upload-time = "2025-10-28T20:59:14.425Z" },
+ { url = "https://files.pythonhosted.org/packages/44/cc/1af9e466eafd9b5d8922238c69aaf95b656137add4c5db65f63ee129bf3c/aiohttp-3.13.2-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:070599407f4954021509193404c4ac53153525a19531051661440644728ba9a7", size = 1553851, upload-time = "2025-10-28T20:59:17.044Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/d1/9e5f4f40f9d0ee5668e9b5e7ebfb0eaf371cc09da03785decdc5da56f4b3/aiohttp-3.13.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:29562998ec66f988d49fb83c9b01694fa927186b781463f376c5845c121e4e0b", size = 1634260, upload-time = "2025-10-28T20:59:19.378Z" },
+ { url = "https://files.pythonhosted.org/packages/83/2e/5d065091c4ae8b55a153f458f19308191bad3b62a89496aa081385486338/aiohttp-3.13.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:4dd3db9d0f4ebca1d887d76f7cdbcd1116ac0d05a9221b9dad82c64a62578c4d", size = 1639499, upload-time = "2025-10-28T20:59:22.013Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/de/58ae6dc73691a51ff16f69a94d13657bf417456fa0fdfed2b59dd6b4c293/aiohttp-3.13.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d7bc4b7f9c4921eba72677cd9fedd2308f4a4ca3e12fab58935295ad9ea98700", size = 1694087, upload-time = "2025-10-28T20:59:24.773Z" },
+ { url = "https://files.pythonhosted.org/packages/45/fe/4d9df516268867d83041b6c073ee15cd532dbea58b82d675a7e1cf2ec24c/aiohttp-3.13.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:dacd50501cd017f8cccb328da0c90823511d70d24a323196826d923aad865901", size = 1540532, upload-time = "2025-10-28T20:59:27.982Z" },
+ { url = "https://files.pythonhosted.org/packages/24/e7/a802619308232499482bf30b3530efb5d141481cfd61850368350fb1acb5/aiohttp-3.13.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:8b2f1414f6a1e0683f212ec80e813f4abef94c739fd090b66c9adf9d2a05feac", size = 1710369, upload-time = "2025-10-28T20:59:30.363Z" },
+ { url = "https://files.pythonhosted.org/packages/62/08/e8593f39f025efe96ef59550d17cf097222d84f6f84798bedac5bf037fce/aiohttp-3.13.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04c3971421576ed24c191f610052bcb2f059e395bc2489dd99e397f9bc466329", size = 1649296, upload-time = "2025-10-28T20:59:33.285Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/fd/ffbc1b6aa46fc6c284af4a438b2c7eab79af1c8ac4b6d2ced185c17f403e/aiohttp-3.13.2-cp39-cp39-win32.whl", hash = "sha256:9f377d0a924e5cc94dc620bc6366fc3e889586a7f18b748901cf016c916e2084", size = 432980, upload-time = "2025-10-28T20:59:35.515Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/a9/d47e7873175a4d8aed425f2cdea2df700b2dd44fac024ffbd83455a69a50/aiohttp-3.13.2-cp39-cp39-win_amd64.whl", hash = "sha256:9c705601e16c03466cb72011bd1af55d68fa65b045356d8f96c216e5f6db0fa5", size = 456021, upload-time = "2025-10-28T20:59:37.659Z" },
]
[[package]]
name = "aiosignal"
-version = "1.3.2"
+version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "frozenlist" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
+]
+
+[[package]]
+name = "argon2-cffi"
+version = "25.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "argon2-cffi-bindings" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424, upload-time = "2024-12-13T17:10:40.86Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
+]
+
+[[package]]
+name = "argon2-cffi-bindings"
+version = "25.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" },
+ { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" },
+ { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" },
+ { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" },
+ { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" },
+ { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" },
+ { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" },
+ { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" },
+ { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
+ { url = "https://files.pythonhosted.org/packages/11/2d/ba4e4ca8d149f8dcc0d952ac0967089e1d759c7e5fcf0865a317eb680fbb/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e", size = 24549, upload-time = "2025-07-30T10:02:00.101Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/82/9b2386cc75ac0bd3210e12a44bfc7fd1632065ed8b80d573036eecb10442/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d", size = 25539, upload-time = "2025-07-30T10:02:00.929Z" },
+ { url = "https://files.pythonhosted.org/packages/31/db/740de99a37aa727623730c90d92c22c9e12585b3c98c54b7960f7810289f/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584", size = 28467, upload-time = "2025-07-30T10:02:02.08Z" },
+ { url = "https://files.pythonhosted.org/packages/71/7a/47c4509ea18d755f44e2b92b7178914f0c113946d11e16e626df8eaa2b0b/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690", size = 27355, upload-time = "2025-07-30T10:02:02.867Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/82/82745642d3c46e7cea25e1885b014b033f4693346ce46b7f47483cf5d448/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520", size = 29187, upload-time = "2025-07-30T10:02:03.674Z" },
]
[[package]]
@@ -137,11 +225,23 @@ wheels = [
[[package]]
name = "attrs"
-version = "25.3.0"
+version = "25.4.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
+]
+
+[[package]]
+name = "authlib"
+version = "1.6.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cd/3f/1d3bbd0bf23bdd99276d4def22f29c27a914067b4cf66f753ff9b8bbd0f3/authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b", size = 164553, upload-time = "2025-10-02T13:36:09.489Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" },
]
[[package]]
@@ -164,99 +264,243 @@ wheels = [
[[package]]
name = "certifi"
-version = "2025.4.26"
+version = "2025.10.5"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" },
+ { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" },
+ { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" },
+ { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" },
+ { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" },
+ { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" },
+ { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" },
+ { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" },
+ { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
+ { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
+ { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
+ { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
+ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
+ { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", size = 184288, upload-time = "2025-09-08T23:23:48.404Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", size = 180509, upload-time = "2025-09-08T23:23:49.73Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", size = 208813, upload-time = "2025-09-08T23:23:51.263Z" },
+ { url = "https://files.pythonhosted.org/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", size = 216498, upload-time = "2025-09-08T23:23:52.494Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", size = 203243, upload-time = "2025-09-08T23:23:53.836Z" },
+ { url = "https://files.pythonhosted.org/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", size = 203158, upload-time = "2025-09-08T23:23:55.169Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", size = 216548, upload-time = "2025-09-08T23:23:56.506Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", size = 218897, upload-time = "2025-09-08T23:23:57.825Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", size = 211249, upload-time = "2025-09-08T23:23:59.139Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/e7/7c769804eb75e4c4b35e658dba01de1640a351a9653c3d49ca89d16ccc91/cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", size = 218041, upload-time = "2025-09-08T23:24:00.496Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/d9/6218d78f920dcd7507fc16a766b5ef8f3b913cc7aa938e7fc80b9978d089/cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", size = 172138, upload-time = "2025-09-08T23:24:01.7Z" },
+ { url = "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", size = 182794, upload-time = "2025-09-08T23:24:02.943Z" },
]
[[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, upload-time = "2025-05-02T08:34:42.01Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818, upload-time = "2025-05-02T08:31:46.725Z" },
- { url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", size = 144649, upload-time = "2025-05-02T08:31:48.889Z" },
- { url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", size = 155045, upload-time = "2025-05-02T08:31:50.757Z" },
- { url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", size = 147356, upload-time = "2025-05-02T08:31:52.634Z" },
- { url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", size = 149471, upload-time = "2025-05-02T08:31:56.207Z" },
- { url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", size = 151317, upload-time = "2025-05-02T08:31:57.613Z" },
- { url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", size = 146368, upload-time = "2025-05-02T08:31:59.468Z" },
- { url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", size = 154491, upload-time = "2025-05-02T08:32:01.219Z" },
- { url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", size = 157695, upload-time = "2025-05-02T08:32:03.045Z" },
- { url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", size = 154849, upload-time = "2025-05-02T08:32:04.651Z" },
- { url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", size = 150091, upload-time = "2025-05-02T08:32:06.719Z" },
- { url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", size = 98445, upload-time = "2025-05-02T08:32:08.66Z" },
- { url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", size = 105782, upload-time = "2025-05-02T08:32:10.46Z" },
- { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" },
- { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" },
- { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" },
- { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" },
- { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" },
- { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" },
- { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" },
- { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" },
- { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" },
- { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" },
- { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" },
- { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" },
- { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" },
- { 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, upload-time = "2025-05-02T08:32:33.712Z" },
- { 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, upload-time = "2025-05-02T08:32:35.768Z" },
- { 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, upload-time = "2025-05-02T08:32:37.284Z" },
- { 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, upload-time = "2025-05-02T08:32:38.803Z" },
- { 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, upload-time = "2025-05-02T08:32:40.251Z" },
- { 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, upload-time = "2025-05-02T08:32:41.705Z" },
- { 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, upload-time = "2025-05-02T08:32:43.709Z" },
- { 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, upload-time = "2025-05-02T08:32:46.197Z" },
- { 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, upload-time = "2025-05-02T08:32:48.105Z" },
- { 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, upload-time = "2025-05-02T08:32:49.719Z" },
- { 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, upload-time = "2025-05-02T08:32:51.404Z" },
- { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" },
- { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" },
- { 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, upload-time = "2025-05-02T08:32:56.363Z" },
- { 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, upload-time = "2025-05-02T08:32:58.551Z" },
- { 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, upload-time = "2025-05-02T08:33:00.342Z" },
- { 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, upload-time = "2025-05-02T08:33:02.081Z" },
- { 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, upload-time = "2025-05-02T08:33:04.063Z" },
- { 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, upload-time = "2025-05-02T08:33:06.418Z" },
- { 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, upload-time = "2025-05-02T08:33:08.183Z" },
- { 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, upload-time = "2025-05-02T08:33:09.986Z" },
- { 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, upload-time = "2025-05-02T08:33:11.814Z" },
- { 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, upload-time = "2025-05-02T08:33:13.707Z" },
- { 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, upload-time = "2025-05-02T08:33:15.458Z" },
- { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" },
- { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" },
- { url = "https://files.pythonhosted.org/packages/28/f8/dfb01ff6cc9af38552c69c9027501ff5a5117c4cc18dcd27cb5259fa1888/charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", size = 201671, upload-time = "2025-05-02T08:34:12.696Z" },
- { url = "https://files.pythonhosted.org/packages/32/fb/74e26ee556a9dbfe3bd264289b67be1e6d616329403036f6507bb9f3f29c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", size = 144744, upload-time = "2025-05-02T08:34:14.665Z" },
- { url = "https://files.pythonhosted.org/packages/ad/06/8499ee5aa7addc6f6d72e068691826ff093329fe59891e83b092ae4c851c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", size = 154993, upload-time = "2025-05-02T08:34:17.134Z" },
- { url = "https://files.pythonhosted.org/packages/f1/a2/5e4c187680728219254ef107a6949c60ee0e9a916a5dadb148c7ae82459c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", size = 147382, upload-time = "2025-05-02T08:34:19.081Z" },
- { url = "https://files.pythonhosted.org/packages/4c/fe/56aca740dda674f0cc1ba1418c4d84534be51f639b5f98f538b332dc9a95/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", size = 149536, upload-time = "2025-05-02T08:34:21.073Z" },
- { url = "https://files.pythonhosted.org/packages/53/13/db2e7779f892386b589173dd689c1b1e304621c5792046edd8a978cbf9e0/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", size = 151349, upload-time = "2025-05-02T08:34:23.193Z" },
- { url = "https://files.pythonhosted.org/packages/69/35/e52ab9a276186f729bce7a0638585d2982f50402046e4b0faa5d2c3ef2da/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", size = 146365, upload-time = "2025-05-02T08:34:25.187Z" },
- { url = "https://files.pythonhosted.org/packages/a6/d8/af7333f732fc2e7635867d56cb7c349c28c7094910c72267586947561b4b/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", size = 154499, upload-time = "2025-05-02T08:34:27.359Z" },
- { url = "https://files.pythonhosted.org/packages/7a/3d/a5b2e48acef264d71e036ff30bcc49e51bde80219bb628ba3e00cf59baac/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", size = 157735, upload-time = "2025-05-02T08:34:29.798Z" },
- { url = "https://files.pythonhosted.org/packages/85/d8/23e2c112532a29f3eef374375a8684a4f3b8e784f62b01da931186f43494/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", size = 154786, upload-time = "2025-05-02T08:34:31.858Z" },
- { url = "https://files.pythonhosted.org/packages/c7/57/93e0169f08ecc20fe82d12254a200dfaceddc1c12a4077bf454ecc597e33/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", size = 150203, upload-time = "2025-05-02T08:34:33.88Z" },
- { url = "https://files.pythonhosted.org/packages/2c/9d/9bf2b005138e7e060d7ebdec7503d0ef3240141587651f4b445bdf7286c2/charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", size = 98436, upload-time = "2025-05-02T08:34:35.907Z" },
- { url = "https://files.pythonhosted.org/packages/6d/24/5849d46cf4311bbf21b424c443b09b459f5b436b1558c04e45dbb7cc478b/charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", size = 105772, upload-time = "2025-05-02T08:34:37.935Z" },
- { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
+version = "3.4.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" },
+ { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" },
+ { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" },
+ { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" },
+ { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
+ { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
+ { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
+ { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
+ { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
+ { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
+ { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
+ { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
+ { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
+ { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
+ { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
+ { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
+ { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
+ { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
+ { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
+ { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
+ { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
+ { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
+ { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
+ { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
+ { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
+ { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
+ { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
+ { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
+ { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
+ { url = "https://files.pythonhosted.org/packages/46/7c/0c4760bccf082737ca7ab84a4c2034fcc06b1f21cf3032ea98bd6feb1725/charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", size = 209609, upload-time = "2025-10-14T04:42:10.922Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/a4/69719daef2f3d7f1819de60c9a6be981b8eeead7542d5ec4440f3c80e111/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", size = 149029, upload-time = "2025-10-14T04:42:12.38Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/21/8d4e1d6c1e6070d3672908b8e4533a71b5b53e71d16828cc24d0efec564c/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608", size = 144580, upload-time = "2025-10-14T04:42:13.549Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/0a/a616d001b3f25647a9068e0b9199f697ce507ec898cacb06a0d5a1617c99/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", size = 162340, upload-time = "2025-10-14T04:42:14.892Z" },
+ { url = "https://files.pythonhosted.org/packages/85/93/060b52deb249a5450460e0585c88a904a83aec474ab8e7aba787f45e79f2/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", size = 159619, upload-time = "2025-10-14T04:42:16.676Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/21/0274deb1cc0632cd587a9a0ec6b4674d9108e461cb4cd40d457adaeb0564/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", size = 153980, upload-time = "2025-10-14T04:42:17.917Z" },
+ { url = "https://files.pythonhosted.org/packages/28/2b/e3d7d982858dccc11b31906976323d790dded2017a0572f093ff982d692f/charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", size = 152174, upload-time = "2025-10-14T04:42:19.018Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/ff/4a269f8e35f1e58b2df52c131a1fa019acb7ef3f8697b7d464b07e9b492d/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", size = 151666, upload-time = "2025-10-14T04:42:20.171Z" },
+ { url = "https://files.pythonhosted.org/packages/da/c9/ec39870f0b330d58486001dd8e532c6b9a905f5765f58a6f8204926b4a93/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", size = 145550, upload-time = "2025-10-14T04:42:21.324Z" },
+ { url = "https://files.pythonhosted.org/packages/75/8f/d186ab99e40e0ed9f82f033d6e49001701c81244d01905dd4a6924191a30/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", size = 163721, upload-time = "2025-10-14T04:42:22.46Z" },
+ { url = "https://files.pythonhosted.org/packages/96/b1/6047663b9744df26a7e479ac1e77af7134b1fcf9026243bb48ee2d18810f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", size = 152127, upload-time = "2025-10-14T04:42:23.712Z" },
+ { url = "https://files.pythonhosted.org/packages/59/78/e5a6eac9179f24f704d1be67d08704c3c6ab9f00963963524be27c18ed87/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", size = 161175, upload-time = "2025-10-14T04:42:24.87Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/43/0e626e42d54dd2f8dd6fc5e1c5ff00f05fbca17cb699bedead2cae69c62f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", size = 155375, upload-time = "2025-10-14T04:42:27.246Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/91/d9615bf2e06f35e4997616ff31248c3657ed649c5ab9d35ea12fce54e380/charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", size = 99692, upload-time = "2025-10-14T04:42:28.425Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a9/6c040053909d9d1ef4fcab45fddec083aedc9052c10078339b47c8573ea8/charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", size = 107192, upload-time = "2025-10-14T04:42:29.482Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/c6/4fa536b2c0cd3edfb7ccf8469fa0f363ea67b7213a842b90909ca33dd851/charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", size = 100220, upload-time = "2025-10-14T04:42:30.632Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "click"
version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.10'",
+]
dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" },
]
+[[package]]
+name = "click"
+version = "8.3.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14'",
+ "python_full_version >= '3.10' and python_full_version < '3.14'",
+]
+dependencies = [
+ { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
+]
+
[[package]]
name = "colorama"
version = "0.4.6"
@@ -268,23 +512,88 @@ wheels = [
[[package]]
name = "crawlerdetect"
-version = "0.3.0"
+version = "0.3.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d4/15/e37598eeb987331dce58bda176e265e311b4fe1330c339ec3741936bd7e6/crawlerdetect-0.3.0.tar.gz", hash = "sha256:a269289943f6b2a8f33ed5b9a09591b6dae0e45c0c19a0d9c5ef07f5c518575c", size = 16574, upload-time = "2024-11-15T08:02:23.521Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/97/f33c16f3ececdfb98582ef0559aee745bcc9fe00d9873fbdb74ad06d7b8b/crawlerdetect-0.3.2.tar.gz", hash = "sha256:1c2f9ccbb786c756c4f5bce62503ac0792b88b0291df6dbd5633f3e9c8a7f432", size = 16775, upload-time = "2025-07-09T16:54:18.415Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/9f/e0df5531907083792578aec5ebb2ec670829317fe6bb63d9cdf78889382b/crawlerdetect-0.3.0-py3-none-any.whl", hash = "sha256:7a9144619f74941bafe76a384aab55143d41d1c999e846eaf61853b3b02633c2", size = 16058, upload-time = "2024-11-15T08:02:21.684Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/47/1be5b2bc4ce8ab32e592817946017efb89f65957c0d17aa346b97c023f18/crawlerdetect-0.3.2-py3-none-any.whl", hash = "sha256:42e53a1fca1f99fc9459d5c699300e94e02d0a23d1cec4750fe37ce61e6e8441", size = 16507, upload-time = "2025-07-09T16:54:16.573Z" },
+]
+
+[[package]]
+name = "cryptography"
+version = "46.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
+ { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
+ { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
+ { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
+ { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
+ { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
+ { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
+ { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
+ { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
+ { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
+ { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
+ { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
+ { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
+ { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
+ { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
+ { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
+ { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
+ { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/cd/1a8633802d766a0fa46f382a77e096d7e209e0817892929655fe0586ae32/cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", size = 3689163, upload-time = "2025-10-15T23:18:13.821Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/59/6b26512964ace6480c3e54681a9859c974172fb141c38df11eadd8416947/cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c", size = 3429474, upload-time = "2025-10-15T23:18:15.477Z" },
+ { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" },
+ { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" },
+ { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" },
]
[[package]]
name = "deprecated"
-version = "1.2.18"
+version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wrapt" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744, upload-time = "2025-01-27T10:46:25.7Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload-time = "2025-01-27T10:46:09.186Z" },
+ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
]
[[package]]
@@ -300,18 +609,34 @@ wheels = [
name = "dnspython"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.10'",
+]
sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" },
]
+[[package]]
+name = "dnspython"
+version = "2.8.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14'",
+ "python_full_version >= '3.10' and python_full_version < '3.14'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
+]
+
[[package]]
name = "emoji"
-version = "2.14.1"
+version = "2.15.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cb/7d/01cddcbb6f5cc0ba72e00ddf9b1fa206c802d557fd0a20b18e130edf1336/emoji-2.14.1.tar.gz", hash = "sha256:f8c50043d79a2c1410ebfae833ae1868d5941a67a6cd4d18377e2eb0bd79346b", size = 597182, upload-time = "2025-01-16T06:31:24.983Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/78/0d2db9382c92a163d7095fc08efff7800880f830a152cfced40161e7638d/emoji-2.15.0.tar.gz", hash = "sha256:eae4ab7d86456a70a00a985125a03263a5eac54cd55e51d7e184b1ed3b6757e4", size = 615483, upload-time = "2025-09-21T12:13:02.755Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/91/db/a0335710caaa6d0aebdaa65ad4df789c15d89b7babd9a30277838a7d9aac/emoji-2.14.1-py3-none-any.whl", hash = "sha256:35a8a486c1460addb1499e3bf7929d3889b2e2841a57401903699fef595e942b", size = 590617, upload-time = "2025-01-16T06:31:23.526Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/4b5aaaabddfacfe36ba7768817bd1f71a7a810a43705e531f3ae4c690767/emoji-2.15.0-py3-none-any.whl", hash = "sha256:205296793d66a89d88af4688fa57fd6496732eb48917a87175a023c8138995eb", size = 608433, upload-time = "2025-09-21T12:13:01.197Z" },
]
[[package]]
@@ -337,29 +662,46 @@ wheels = [
[[package]]
name = "filelock"
-version = "3.18.0"
+version = "3.19.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" }
+resolution-markers = [
+ "python_full_version < '3.10'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" },
+ { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" },
+]
+
+[[package]]
+name = "filelock"
+version = "3.20.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14'",
+ "python_full_version >= '3.10' and python_full_version < '3.14'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" },
]
[[package]]
name = "flask"
-version = "3.1.1"
+version = "3.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
- { name = "click" },
+ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "click", version = "8.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "importlib-metadata", marker = "python_full_version < '3.10'" },
{ 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, upload-time = "2025-05-13T15:01:17.447Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305, upload-time = "2025-05-13T15:01:15.591Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
]
[[package]]
@@ -377,27 +719,30 @@ wheels = [
[[package]]
name = "flask-cors"
-version = "6.0.0"
+version = "6.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "werkzeug" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/20/e7/b3c6afdd984672b55dff07482699c688af6c01bd7fd5dd55f9c9d1a88d1c/flask_cors-6.0.0.tar.gz", hash = "sha256:4592c1570246bf7beee96b74bc0adbbfcb1b0318f6ba05c412e8909eceec3393", size = 11875, upload-time = "2025-05-17T14:35:16.98Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/37/bcfa6c7d5eec777c4c7cf45ce6b27631cebe5230caf88d85eadd63edd37a/flask_cors-6.0.1.tar.gz", hash = "sha256:d81bcb31f07b0985be7f48406247e9243aced229b7747219160a0559edd678db", size = 13463, upload-time = "2025-06-11T01:32:08.518Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ba/f0/0ee29090016345938f016ee98aa8b5de1c500ee93491dc0c76495848fca1/flask_cors-6.0.0-py3-none-any.whl", hash = "sha256:6332073356452343a8ccddbfec7befdc3fdd040141fe776ec9b94c262f058657", size = 11549, upload-time = "2025-05-17T14:35:15.766Z" },
+ { url = "https://files.pythonhosted.org/packages/17/f8/01bf35a3afd734345528f98d0353f2a978a476528ad4d7e78b70c4d149dd/flask_cors-6.0.1-py3-none-any.whl", hash = "sha256:c7b2cbfb1a31aa0d2e5341eea03a6805349f7a61647daee1a15c46bbe981494c", size = 13244, upload-time = "2025-06-11T01:32:07.352Z" },
]
[[package]]
name = "flask-limiter"
version = "3.11.0"
source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.10'",
+]
dependencies = [
- { name = "flask" },
- { name = "limits" },
- { name = "ordered-set" },
- { name = "rich" },
- { name = "typing-extensions" },
+ { name = "flask", marker = "python_full_version < '3.10'" },
+ { name = "limits", version = "4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "ordered-set", marker = "python_full_version < '3.10'" },
+ { name = "rich", version = "13.9.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9c/a4/02f67783825a4207d4ae7de4c8be45596c3fba5b65ace77fd6dc3878020d/flask_limiter-3.11.0.tar.gz", hash = "sha256:57b037fb8be423ef7ebac4fbb279fbfdc42d9aa5378467ab6798d6ce3d912117", size = 303361, upload-time = "2025-03-11T20:37:59.839Z" }
wheels = [
@@ -406,118 +751,169 @@ wheels = [
[package.optional-dependencies]
mongodb = [
- { name = "limits", extra = ["mongodb"] },
+ { name = "limits", version = "4.2", source = { registry = "https://pypi.org/simple" }, extra = ["mongodb"], marker = "python_full_version < '3.10'" },
]
[[package]]
-name = "frozenlist"
-version = "1.6.0"
+name = "flask-limiter"
+version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ee/f4/d744cba2da59b5c1d88823cf9e8a6c74e4659e2b27604ed973be2a0bf5ab/frozenlist-1.6.0.tar.gz", hash = "sha256:b99655c32c1c8e06d111e7f41c06c29a5318cb1835df23a45518e02a47c63b68", size = 42831, upload-time = "2025-04-17T22:38:53.099Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/44/03/22e4eb297981d48468c3d9982ab6076b10895106d3039302a943bb60fd70/frozenlist-1.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e6e558ea1e47fd6fa8ac9ccdad403e5dd5ecc6ed8dda94343056fa4277d5c65e", size = 160584, upload-time = "2025-04-17T22:35:48.163Z" },
- { url = "https://files.pythonhosted.org/packages/2b/b8/c213e35bcf1c20502c6fd491240b08cdd6ceec212ea54873f4cae99a51e4/frozenlist-1.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4b3cd7334a4bbc0c472164f3744562cb72d05002cc6fcf58adb104630bbc352", size = 124099, upload-time = "2025-04-17T22:35:50.241Z" },
- { url = "https://files.pythonhosted.org/packages/2b/33/df17b921c2e37b971407b4045deeca6f6de7caf0103c43958da5e1b85e40/frozenlist-1.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9799257237d0479736e2b4c01ff26b5c7f7694ac9692a426cb717f3dc02fff9b", size = 122106, upload-time = "2025-04-17T22:35:51.697Z" },
- { url = "https://files.pythonhosted.org/packages/8e/09/93f0293e8a95c05eea7cf9277fef8929fb4d0a2234ad9394cd2a6b6a6bb4/frozenlist-1.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a7bb0fe1f7a70fb5c6f497dc32619db7d2cdd53164af30ade2f34673f8b1fc", size = 287205, upload-time = "2025-04-17T22:35:53.441Z" },
- { url = "https://files.pythonhosted.org/packages/5e/34/35612f6f1b1ae0f66a4058599687d8b39352ade8ed329df0890fb553ea1e/frozenlist-1.6.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:36d2fc099229f1e4237f563b2a3e0ff7ccebc3999f729067ce4e64a97a7f2869", size = 295079, upload-time = "2025-04-17T22:35:55.617Z" },
- { url = "https://files.pythonhosted.org/packages/e5/ca/51577ef6cc4ec818aab94a0034ef37808d9017c2e53158fef8834dbb3a07/frozenlist-1.6.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f27a9f9a86dcf00708be82359db8de86b80d029814e6693259befe82bb58a106", size = 308068, upload-time = "2025-04-17T22:35:57.119Z" },
- { url = "https://files.pythonhosted.org/packages/36/27/c63a23863b9dcbd064560f0fea41b516bbbf4d2e8e7eec3ff880a96f0224/frozenlist-1.6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75ecee69073312951244f11b8627e3700ec2bfe07ed24e3a685a5979f0412d24", size = 305640, upload-time = "2025-04-17T22:35:58.667Z" },
- { url = "https://files.pythonhosted.org/packages/33/c2/91720b3562a6073ba604547a417c8d3bf5d33e4c8f1231f3f8ff6719e05c/frozenlist-1.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2c7d5aa19714b1b01a0f515d078a629e445e667b9da869a3cd0e6fe7dec78bd", size = 278509, upload-time = "2025-04-17T22:36:00.199Z" },
- { url = "https://files.pythonhosted.org/packages/d0/6e/1b64671ab2fca1ebf32c5b500205724ac14c98b9bc1574b2ef55853f4d71/frozenlist-1.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69bbd454f0fb23b51cadc9bdba616c9678e4114b6f9fa372d462ff2ed9323ec8", size = 287318, upload-time = "2025-04-17T22:36:02.179Z" },
- { url = "https://files.pythonhosted.org/packages/66/30/589a8d8395d5ebe22a6b21262a4d32876df822c9a152e9f2919967bb8e1a/frozenlist-1.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7daa508e75613809c7a57136dec4871a21bca3080b3a8fc347c50b187df4f00c", size = 290923, upload-time = "2025-04-17T22:36:03.766Z" },
- { url = "https://files.pythonhosted.org/packages/4d/e0/2bd0d2a4a7062b7e4b5aad621697cd3579e5d1c39d99f2833763d91e746d/frozenlist-1.6.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:89ffdb799154fd4d7b85c56d5fa9d9ad48946619e0eb95755723fffa11022d75", size = 304847, upload-time = "2025-04-17T22:36:05.518Z" },
- { url = "https://files.pythonhosted.org/packages/70/a0/a1a44204398a4b308c3ee1b7bf3bf56b9dcbcc4e61c890e038721d1498db/frozenlist-1.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:920b6bd77d209931e4c263223381d63f76828bec574440f29eb497cf3394c249", size = 285580, upload-time = "2025-04-17T22:36:07.538Z" },
- { url = "https://files.pythonhosted.org/packages/78/ed/3862bc9abe05839a6a5f5bab8b6bbdf0fc9369505cb77cd15b8c8948f6a0/frozenlist-1.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d3ceb265249fb401702fce3792e6b44c1166b9319737d21495d3611028d95769", size = 304033, upload-time = "2025-04-17T22:36:09.082Z" },
- { url = "https://files.pythonhosted.org/packages/2c/9c/1c48454a9e1daf810aa6d977626c894b406651ca79d722fce0f13c7424f1/frozenlist-1.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52021b528f1571f98a7d4258c58aa8d4b1a96d4f01d00d51f1089f2e0323cb02", size = 307566, upload-time = "2025-04-17T22:36:10.561Z" },
- { url = "https://files.pythonhosted.org/packages/35/ef/cb43655c21f1bad5c42bcd540095bba6af78bf1e474b19367f6fd67d029d/frozenlist-1.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0f2ca7810b809ed0f1917293050163c7654cefc57a49f337d5cd9de717b8fad3", size = 295354, upload-time = "2025-04-17T22:36:12.181Z" },
- { url = "https://files.pythonhosted.org/packages/9f/59/d8069a688a0f54a968c73300d6013e4786b029bfec308664094130dcea66/frozenlist-1.6.0-cp310-cp310-win32.whl", hash = "sha256:0e6f8653acb82e15e5443dba415fb62a8732b68fe09936bb6d388c725b57f812", size = 115586, upload-time = "2025-04-17T22:36:14.01Z" },
- { url = "https://files.pythonhosted.org/packages/f9/a6/8f0cef021912ba7aa3b9920fe0a4557f6e85c41bbf71bb568cd744828df5/frozenlist-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f1a39819a5a3e84304cd286e3dc62a549fe60985415851b3337b6f5cc91907f1", size = 120845, upload-time = "2025-04-17T22:36:15.383Z" },
- { url = "https://files.pythonhosted.org/packages/53/b5/bc883b5296ec902115c00be161da93bf661199c465ec4c483feec6ea4c32/frozenlist-1.6.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae8337990e7a45683548ffb2fee1af2f1ed08169284cd829cdd9a7fa7470530d", size = 160912, upload-time = "2025-04-17T22:36:17.235Z" },
- { url = "https://files.pythonhosted.org/packages/6f/93/51b058b563d0704b39c56baa222828043aafcac17fd3734bec5dbeb619b1/frozenlist-1.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8c952f69dd524558694818a461855f35d36cc7f5c0adddce37e962c85d06eac0", size = 124315, upload-time = "2025-04-17T22:36:18.735Z" },
- { url = "https://files.pythonhosted.org/packages/c9/e0/46cd35219428d350558b874d595e132d1c17a9471a1bd0d01d518a261e7c/frozenlist-1.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8f5fef13136c4e2dee91bfb9a44e236fff78fc2cd9f838eddfc470c3d7d90afe", size = 122230, upload-time = "2025-04-17T22:36:20.6Z" },
- { url = "https://files.pythonhosted.org/packages/d1/0f/7ad2ce928ad06d6dd26a61812b959ded573d3e9d0ee6109d96c2be7172e9/frozenlist-1.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:716bbba09611b4663ecbb7cd022f640759af8259e12a6ca939c0a6acd49eedba", size = 314842, upload-time = "2025-04-17T22:36:22.088Z" },
- { url = "https://files.pythonhosted.org/packages/34/76/98cbbd8a20a5c3359a2004ae5e5b216af84a150ccbad67c8f8f30fb2ea91/frozenlist-1.6.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7b8c4dc422c1a3ffc550b465090e53b0bf4839047f3e436a34172ac67c45d595", size = 304919, upload-time = "2025-04-17T22:36:24.247Z" },
- { url = "https://files.pythonhosted.org/packages/9a/fa/258e771ce3a44348c05e6b01dffc2bc67603fba95761458c238cd09a2c77/frozenlist-1.6.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b11534872256e1666116f6587a1592ef395a98b54476addb5e8d352925cb5d4a", size = 324074, upload-time = "2025-04-17T22:36:26.291Z" },
- { url = "https://files.pythonhosted.org/packages/d5/a4/047d861fd8c538210e12b208c0479912273f991356b6bdee7ea8356b07c9/frozenlist-1.6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c6eceb88aaf7221f75be6ab498dc622a151f5f88d536661af3ffc486245a626", size = 321292, upload-time = "2025-04-17T22:36:27.909Z" },
- { url = "https://files.pythonhosted.org/packages/c0/25/cfec8af758b4525676cabd36efcaf7102c1348a776c0d1ad046b8a7cdc65/frozenlist-1.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62c828a5b195570eb4b37369fcbbd58e96c905768d53a44d13044355647838ff", size = 301569, upload-time = "2025-04-17T22:36:29.448Z" },
- { url = "https://files.pythonhosted.org/packages/87/2f/0c819372fa9f0c07b153124bf58683b8d0ca7bb73ea5ccde9b9ef1745beb/frozenlist-1.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1c6bd2c6399920c9622362ce95a7d74e7f9af9bfec05fff91b8ce4b9647845a", size = 313625, upload-time = "2025-04-17T22:36:31.55Z" },
- { url = "https://files.pythonhosted.org/packages/50/5f/f0cf8b0fdedffdb76b3745aa13d5dbe404d63493cc211ce8250f2025307f/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49ba23817781e22fcbd45fd9ff2b9b8cdb7b16a42a4851ab8025cae7b22e96d0", size = 312523, upload-time = "2025-04-17T22:36:33.078Z" },
- { url = "https://files.pythonhosted.org/packages/e1/6c/38c49108491272d3e84125bbabf2c2d0b304899b52f49f0539deb26ad18d/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:431ef6937ae0f853143e2ca67d6da76c083e8b1fe3df0e96f3802fd37626e606", size = 322657, upload-time = "2025-04-17T22:36:34.688Z" },
- { url = "https://files.pythonhosted.org/packages/bd/4b/3bd3bad5be06a9d1b04b1c22be80b5fe65b502992d62fab4bdb25d9366ee/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9d124b38b3c299ca68433597ee26b7819209cb8a3a9ea761dfe9db3a04bba584", size = 303414, upload-time = "2025-04-17T22:36:36.363Z" },
- { url = "https://files.pythonhosted.org/packages/5b/89/7e225a30bef6e85dbfe22622c24afe932e9444de3b40d58b1ea589a14ef8/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:118e97556306402e2b010da1ef21ea70cb6d6122e580da64c056b96f524fbd6a", size = 320321, upload-time = "2025-04-17T22:36:38.16Z" },
- { url = "https://files.pythonhosted.org/packages/22/72/7e3acef4dd9e86366cb8f4d8f28e852c2b7e116927e9722b31a6f71ea4b0/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fb3b309f1d4086b5533cf7bbcf3f956f0ae6469664522f1bde4feed26fba60f1", size = 323975, upload-time = "2025-04-17T22:36:40.289Z" },
- { url = "https://files.pythonhosted.org/packages/d8/85/e5da03d20507e13c66ce612c9792b76811b7a43e3320cce42d95b85ac755/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54dece0d21dce4fdb188a1ffc555926adf1d1c516e493c2914d7c370e454bc9e", size = 316553, upload-time = "2025-04-17T22:36:42.045Z" },
- { url = "https://files.pythonhosted.org/packages/ac/8e/6c609cbd0580ae8a0661c408149f196aade7d325b1ae7adc930501b81acb/frozenlist-1.6.0-cp311-cp311-win32.whl", hash = "sha256:654e4ba1d0b2154ca2f096bed27461cf6160bc7f504a7f9a9ef447c293caf860", size = 115511, upload-time = "2025-04-17T22:36:44.067Z" },
- { url = "https://files.pythonhosted.org/packages/f2/13/a84804cfde6de12d44ed48ecbf777ba62b12ff09e761f76cdd1ff9e14bb1/frozenlist-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e911391bffdb806001002c1f860787542f45916c3baf764264a52765d5a5603", size = 120863, upload-time = "2025-04-17T22:36:45.465Z" },
- { url = "https://files.pythonhosted.org/packages/9c/8a/289b7d0de2fbac832ea80944d809759976f661557a38bb8e77db5d9f79b7/frozenlist-1.6.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c5b9e42ace7d95bf41e19b87cec8f262c41d3510d8ad7514ab3862ea2197bfb1", size = 160193, upload-time = "2025-04-17T22:36:47.382Z" },
- { url = "https://files.pythonhosted.org/packages/19/80/2fd17d322aec7f430549f0669f599997174f93ee17929ea5b92781ec902c/frozenlist-1.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ca9973735ce9f770d24d5484dcb42f68f135351c2fc81a7a9369e48cf2998a29", size = 123831, upload-time = "2025-04-17T22:36:49.401Z" },
- { url = "https://files.pythonhosted.org/packages/99/06/f5812da431273f78c6543e0b2f7de67dfd65eb0a433978b2c9c63d2205e4/frozenlist-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ac40ec76041c67b928ca8aaffba15c2b2ee3f5ae8d0cb0617b5e63ec119ca25", size = 121862, upload-time = "2025-04-17T22:36:51.899Z" },
- { url = "https://files.pythonhosted.org/packages/d0/31/9e61c6b5fc493cf24d54881731204d27105234d09878be1a5983182cc4a5/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95b7a8a3180dfb280eb044fdec562f9b461614c0ef21669aea6f1d3dac6ee576", size = 316361, upload-time = "2025-04-17T22:36:53.402Z" },
- { url = "https://files.pythonhosted.org/packages/9d/55/22ca9362d4f0222324981470fd50192be200154d51509ee6eb9baa148e96/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c444d824e22da6c9291886d80c7d00c444981a72686e2b59d38b285617cb52c8", size = 307115, upload-time = "2025-04-17T22:36:55.016Z" },
- { url = "https://files.pythonhosted.org/packages/ae/39/4fff42920a57794881e7bb3898dc7f5f539261711ea411b43bba3cde8b79/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb52c8166499a8150bfd38478248572c924c003cbb45fe3bcd348e5ac7c000f9", size = 322505, upload-time = "2025-04-17T22:36:57.12Z" },
- { url = "https://files.pythonhosted.org/packages/55/f2/88c41f374c1e4cf0092a5459e5f3d6a1e17ed274c98087a76487783df90c/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b35298b2db9c2468106278537ee529719228950a5fdda686582f68f247d1dc6e", size = 322666, upload-time = "2025-04-17T22:36:58.735Z" },
- { url = "https://files.pythonhosted.org/packages/75/51/034eeb75afdf3fd03997856195b500722c0b1a50716664cde64e28299c4b/frozenlist-1.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d108e2d070034f9d57210f22fefd22ea0d04609fc97c5f7f5a686b3471028590", size = 302119, upload-time = "2025-04-17T22:37:00.512Z" },
- { url = "https://files.pythonhosted.org/packages/2b/a6/564ecde55ee633270a793999ef4fd1d2c2b32b5a7eec903b1012cb7c5143/frozenlist-1.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e1be9111cb6756868ac242b3c2bd1f09d9aea09846e4f5c23715e7afb647103", size = 316226, upload-time = "2025-04-17T22:37:02.102Z" },
- { url = "https://files.pythonhosted.org/packages/f1/c8/6c0682c32377f402b8a6174fb16378b683cf6379ab4d2827c580892ab3c7/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:94bb451c664415f02f07eef4ece976a2c65dcbab9c2f1705b7031a3a75349d8c", size = 312788, upload-time = "2025-04-17T22:37:03.578Z" },
- { url = "https://files.pythonhosted.org/packages/b6/b8/10fbec38f82c5d163ca1750bfff4ede69713badf236a016781cf1f10a0f0/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d1a686d0b0949182b8faddea596f3fc11f44768d1f74d4cad70213b2e139d821", size = 325914, upload-time = "2025-04-17T22:37:05.213Z" },
- { url = "https://files.pythonhosted.org/packages/62/ca/2bf4f3a1bd40cdedd301e6ecfdbb291080d5afc5f9ce350c0739f773d6b9/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ea8e59105d802c5a38bdbe7362822c522230b3faba2aa35c0fa1765239b7dd70", size = 305283, upload-time = "2025-04-17T22:37:06.985Z" },
- { url = "https://files.pythonhosted.org/packages/09/64/20cc13ccf94abc2a1f482f74ad210703dc78a590d0b805af1c9aa67f76f9/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:abc4e880a9b920bc5020bf6a431a6bb40589d9bca3975c980495f63632e8382f", size = 319264, upload-time = "2025-04-17T22:37:08.618Z" },
- { url = "https://files.pythonhosted.org/packages/20/ff/86c6a2bbe98cfc231519f5e6d712a0898488ceac804a917ce014f32e68f6/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9a79713adfe28830f27a3c62f6b5406c37376c892b05ae070906f07ae4487046", size = 326482, upload-time = "2025-04-17T22:37:10.196Z" },
- { url = "https://files.pythonhosted.org/packages/2f/da/8e381f66367d79adca245d1d71527aac774e30e291d41ef161ce2d80c38e/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a0318c2068e217a8f5e3b85e35899f5a19e97141a45bb925bb357cfe1daf770", size = 318248, upload-time = "2025-04-17T22:37:12.284Z" },
- { url = "https://files.pythonhosted.org/packages/39/24/1a1976563fb476ab6f0fa9fefaac7616a4361dbe0461324f9fd7bf425dbe/frozenlist-1.6.0-cp312-cp312-win32.whl", hash = "sha256:853ac025092a24bb3bf09ae87f9127de9fe6e0c345614ac92536577cf956dfcc", size = 115161, upload-time = "2025-04-17T22:37:13.902Z" },
- { url = "https://files.pythonhosted.org/packages/80/2e/fb4ed62a65f8cd66044706b1013f0010930d8cbb0729a2219561ea075434/frozenlist-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:2bdfe2d7e6c9281c6e55523acd6c2bf77963cb422fdc7d142fb0cb6621b66878", size = 120548, upload-time = "2025-04-17T22:37:15.326Z" },
- { url = "https://files.pythonhosted.org/packages/6f/e5/04c7090c514d96ca00887932417f04343ab94904a56ab7f57861bf63652d/frozenlist-1.6.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1d7fb014fe0fbfee3efd6a94fc635aeaa68e5e1720fe9e57357f2e2c6e1a647e", size = 158182, upload-time = "2025-04-17T22:37:16.837Z" },
- { url = "https://files.pythonhosted.org/packages/e9/8f/60d0555c61eec855783a6356268314d204137f5e0c53b59ae2fc28938c99/frozenlist-1.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01bcaa305a0fdad12745502bfd16a1c75b14558dabae226852f9159364573117", size = 122838, upload-time = "2025-04-17T22:37:18.352Z" },
- { url = "https://files.pythonhosted.org/packages/5a/a7/d0ec890e3665b4b3b7c05dc80e477ed8dc2e2e77719368e78e2cd9fec9c8/frozenlist-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b314faa3051a6d45da196a2c495e922f987dc848e967d8cfeaee8a0328b1cd4", size = 120980, upload-time = "2025-04-17T22:37:19.857Z" },
- { url = "https://files.pythonhosted.org/packages/cc/19/9b355a5e7a8eba903a008579964192c3e427444752f20b2144b10bb336df/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da62fecac21a3ee10463d153549d8db87549a5e77eefb8c91ac84bb42bb1e4e3", size = 305463, upload-time = "2025-04-17T22:37:21.328Z" },
- { url = "https://files.pythonhosted.org/packages/9c/8d/5b4c758c2550131d66935ef2fa700ada2461c08866aef4229ae1554b93ca/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1eb89bf3454e2132e046f9599fbcf0a4483ed43b40f545551a39316d0201cd1", size = 297985, upload-time = "2025-04-17T22:37:23.55Z" },
- { url = "https://files.pythonhosted.org/packages/48/2c/537ec09e032b5865715726b2d1d9813e6589b571d34d01550c7aeaad7e53/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18689b40cb3936acd971f663ccb8e2589c45db5e2c5f07e0ec6207664029a9c", size = 311188, upload-time = "2025-04-17T22:37:25.221Z" },
- { url = "https://files.pythonhosted.org/packages/31/2f/1aa74b33f74d54817055de9a4961eff798f066cdc6f67591905d4fc82a84/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e67ddb0749ed066b1a03fba812e2dcae791dd50e5da03be50b6a14d0c1a9ee45", size = 311874, upload-time = "2025-04-17T22:37:26.791Z" },
- { url = "https://files.pythonhosted.org/packages/bf/f0/cfec18838f13ebf4b37cfebc8649db5ea71a1b25dacd691444a10729776c/frozenlist-1.6.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc5e64626e6682638d6e44398c9baf1d6ce6bc236d40b4b57255c9d3f9761f1f", size = 291897, upload-time = "2025-04-17T22:37:28.958Z" },
- { url = "https://files.pythonhosted.org/packages/ea/a5/deb39325cbbea6cd0a46db8ccd76150ae2fcbe60d63243d9df4a0b8c3205/frozenlist-1.6.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:437cfd39564744ae32ad5929e55b18ebd88817f9180e4cc05e7d53b75f79ce85", size = 305799, upload-time = "2025-04-17T22:37:30.889Z" },
- { url = "https://files.pythonhosted.org/packages/78/22/6ddec55c5243a59f605e4280f10cee8c95a449f81e40117163383829c241/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62dd7df78e74d924952e2feb7357d826af8d2f307557a779d14ddf94d7311be8", size = 302804, upload-time = "2025-04-17T22:37:32.489Z" },
- { url = "https://files.pythonhosted.org/packages/5d/b7/d9ca9bab87f28855063c4d202936800219e39db9e46f9fb004d521152623/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a66781d7e4cddcbbcfd64de3d41a61d6bdde370fc2e38623f30b2bd539e84a9f", size = 316404, upload-time = "2025-04-17T22:37:34.59Z" },
- { url = "https://files.pythonhosted.org/packages/a6/3a/1255305db7874d0b9eddb4fe4a27469e1fb63720f1fc6d325a5118492d18/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:482fe06e9a3fffbcd41950f9d890034b4a54395c60b5e61fae875d37a699813f", size = 295572, upload-time = "2025-04-17T22:37:36.337Z" },
- { url = "https://files.pythonhosted.org/packages/2a/f2/8d38eeee39a0e3a91b75867cc102159ecccf441deb6ddf67be96d3410b84/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e4f9373c500dfc02feea39f7a56e4f543e670212102cc2eeb51d3a99c7ffbde6", size = 307601, upload-time = "2025-04-17T22:37:37.923Z" },
- { url = "https://files.pythonhosted.org/packages/38/04/80ec8e6b92f61ef085422d7b196822820404f940950dde5b2e367bede8bc/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e69bb81de06827147b7bfbaeb284d85219fa92d9f097e32cc73675f279d70188", size = 314232, upload-time = "2025-04-17T22:37:39.669Z" },
- { url = "https://files.pythonhosted.org/packages/3a/58/93b41fb23e75f38f453ae92a2f987274c64637c450285577bd81c599b715/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7613d9977d2ab4a9141dde4a149f4357e4065949674c5649f920fec86ecb393e", size = 308187, upload-time = "2025-04-17T22:37:41.662Z" },
- { url = "https://files.pythonhosted.org/packages/6a/a2/e64df5c5aa36ab3dee5a40d254f3e471bb0603c225f81664267281c46a2d/frozenlist-1.6.0-cp313-cp313-win32.whl", hash = "sha256:4def87ef6d90429f777c9d9de3961679abf938cb6b7b63d4a7eb8a268babfce4", size = 114772, upload-time = "2025-04-17T22:37:43.132Z" },
- { url = "https://files.pythonhosted.org/packages/a0/77/fead27441e749b2d574bb73d693530d59d520d4b9e9679b8e3cb779d37f2/frozenlist-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:37a8a52c3dfff01515e9bbbee0e6063181362f9de3db2ccf9bc96189b557cbfd", size = 119847, upload-time = "2025-04-17T22:37:45.118Z" },
- { url = "https://files.pythonhosted.org/packages/df/bd/cc6d934991c1e5d9cafda83dfdc52f987c7b28343686aef2e58a9cf89f20/frozenlist-1.6.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:46138f5a0773d064ff663d273b309b696293d7a7c00a0994c5c13a5078134b64", size = 174937, upload-time = "2025-04-17T22:37:46.635Z" },
- { url = "https://files.pythonhosted.org/packages/f2/a2/daf945f335abdbfdd5993e9dc348ef4507436936ab3c26d7cfe72f4843bf/frozenlist-1.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f88bc0a2b9c2a835cb888b32246c27cdab5740059fb3688852bf91e915399b91", size = 136029, upload-time = "2025-04-17T22:37:48.192Z" },
- { url = "https://files.pythonhosted.org/packages/51/65/4c3145f237a31247c3429e1c94c384d053f69b52110a0d04bfc8afc55fb2/frozenlist-1.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:777704c1d7655b802c7850255639672e90e81ad6fa42b99ce5ed3fbf45e338dd", size = 134831, upload-time = "2025-04-17T22:37:50.485Z" },
- { url = "https://files.pythonhosted.org/packages/77/38/03d316507d8dea84dfb99bdd515ea245628af964b2bf57759e3c9205cc5e/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85ef8d41764c7de0dcdaf64f733a27352248493a85a80661f3c678acd27e31f2", size = 392981, upload-time = "2025-04-17T22:37:52.558Z" },
- { url = "https://files.pythonhosted.org/packages/37/02/46285ef9828f318ba400a51d5bb616ded38db8466836a9cfa39f3903260b/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:da5cb36623f2b846fb25009d9d9215322318ff1c63403075f812b3b2876c8506", size = 371999, upload-time = "2025-04-17T22:37:54.092Z" },
- { url = "https://files.pythonhosted.org/packages/0d/64/1212fea37a112c3c5c05bfb5f0a81af4836ce349e69be75af93f99644da9/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cbb56587a16cf0fb8acd19e90ff9924979ac1431baea8681712716a8337577b0", size = 392200, upload-time = "2025-04-17T22:37:55.951Z" },
- { url = "https://files.pythonhosted.org/packages/81/ce/9a6ea1763e3366e44a5208f76bf37c76c5da570772375e4d0be85180e588/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6154c3ba59cda3f954c6333025369e42c3acd0c6e8b6ce31eb5c5b8116c07e0", size = 390134, upload-time = "2025-04-17T22:37:57.633Z" },
- { url = "https://files.pythonhosted.org/packages/bc/36/939738b0b495b2c6d0c39ba51563e453232813042a8d908b8f9544296c29/frozenlist-1.6.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e8246877afa3f1ae5c979fe85f567d220f86a50dc6c493b9b7d8191181ae01e", size = 365208, upload-time = "2025-04-17T22:37:59.742Z" },
- { url = "https://files.pythonhosted.org/packages/b4/8b/939e62e93c63409949c25220d1ba8e88e3960f8ef6a8d9ede8f94b459d27/frozenlist-1.6.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b0f6cce16306d2e117cf9db71ab3a9e8878a28176aeaf0dbe35248d97b28d0c", size = 385548, upload-time = "2025-04-17T22:38:01.416Z" },
- { url = "https://files.pythonhosted.org/packages/62/38/22d2873c90102e06a7c5a3a5b82ca47e393c6079413e8a75c72bff067fa8/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1b8e8cd8032ba266f91136d7105706ad57770f3522eac4a111d77ac126a25a9b", size = 391123, upload-time = "2025-04-17T22:38:03.049Z" },
- { url = "https://files.pythonhosted.org/packages/44/78/63aaaf533ee0701549500f6d819be092c6065cb5c577edb70c09df74d5d0/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e2ada1d8515d3ea5378c018a5f6d14b4994d4036591a52ceaf1a1549dec8e1ad", size = 394199, upload-time = "2025-04-17T22:38:04.776Z" },
- { url = "https://files.pythonhosted.org/packages/54/45/71a6b48981d429e8fbcc08454dc99c4c2639865a646d549812883e9c9dd3/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:cdb2c7f071e4026c19a3e32b93a09e59b12000751fc9b0b7758da899e657d215", size = 373854, upload-time = "2025-04-17T22:38:06.576Z" },
- { url = "https://files.pythonhosted.org/packages/3f/f3/dbf2a5e11736ea81a66e37288bf9f881143a7822b288a992579ba1b4204d/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:03572933a1969a6d6ab509d509e5af82ef80d4a5d4e1e9f2e1cdd22c77a3f4d2", size = 395412, upload-time = "2025-04-17T22:38:08.197Z" },
- { url = "https://files.pythonhosted.org/packages/b3/f1/c63166806b331f05104d8ea385c4acd511598568b1f3e4e8297ca54f2676/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:77effc978947548b676c54bbd6a08992759ea6f410d4987d69feea9cd0919911", size = 394936, upload-time = "2025-04-17T22:38:10.056Z" },
- { url = "https://files.pythonhosted.org/packages/ef/ea/4f3e69e179a430473eaa1a75ff986526571215fefc6b9281cdc1f09a4eb8/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a2bda8be77660ad4089caf2223fdbd6db1858462c4b85b67fbfa22102021e497", size = 391459, upload-time = "2025-04-17T22:38:11.826Z" },
- { url = "https://files.pythonhosted.org/packages/d3/c3/0fc2c97dea550df9afd072a37c1e95421652e3206bbeaa02378b24c2b480/frozenlist-1.6.0-cp313-cp313t-win32.whl", hash = "sha256:a4d96dc5bcdbd834ec6b0f91027817214216b5b30316494d2b1aebffb87c534f", size = 128797, upload-time = "2025-04-17T22:38:14.013Z" },
- { url = "https://files.pythonhosted.org/packages/ae/f5/79c9320c5656b1965634fe4be9c82b12a3305bdbc58ad9cb941131107b20/frozenlist-1.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e18036cb4caa17ea151fd5f3d70be9d354c99eb8cf817a3ccde8a7873b074348", size = 134709, upload-time = "2025-04-17T22:38:15.551Z" },
- { url = "https://files.pythonhosted.org/packages/11/87/9555739639476dfc4a5b9b675a8afaf79c71704dcdd490fde94f882c3f08/frozenlist-1.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:536a1236065c29980c15c7229fbb830dedf809708c10e159b8136534233545f0", size = 161525, upload-time = "2025-04-17T22:38:17.058Z" },
- { url = "https://files.pythonhosted.org/packages/43/75/c5381e02933ad138af448d0e995aff30fd25cc23fc45287c7bc4df6200c8/frozenlist-1.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ed5e3a4462ff25ca84fb09e0fada8ea267df98a450340ead4c91b44857267d70", size = 124569, upload-time = "2025-04-17T22:38:19.177Z" },
- { url = "https://files.pythonhosted.org/packages/82/63/1275253c9960cb7bd584dd44c6367cd83759c063c807496c4e1d4b5ded4a/frozenlist-1.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e19c0fc9f4f030fcae43b4cdec9e8ab83ffe30ec10c79a4a43a04d1af6c5e1ad", size = 122634, upload-time = "2025-04-17T22:38:20.682Z" },
- { url = "https://files.pythonhosted.org/packages/ea/5e/4a102f3d72517b6f70c053befcec2e764223f438855b40296507e1377fec/frozenlist-1.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c608f833897501dac548585312d73a7dca028bf3b8688f0d712b7acfaf7fb3", size = 288320, upload-time = "2025-04-17T22:38:22.278Z" },
- { url = "https://files.pythonhosted.org/packages/92/db/40c79258a4ecca09b9ddfd9e9ac8d27587644fccfa276cea11c316fec1af/frozenlist-1.6.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0dbae96c225d584f834b8d3cc688825911960f003a85cb0fd20b6e5512468c42", size = 297813, upload-time = "2025-04-17T22:38:23.984Z" },
- { url = "https://files.pythonhosted.org/packages/62/ad/cd053d17f56770545ab361c8be63e0bc71d003c3759d9b0d4b13c9e2377b/frozenlist-1.6.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:625170a91dd7261a1d1c2a0c1a353c9e55d21cd67d0852185a5fef86587e6f5f", size = 311027, upload-time = "2025-04-17T22:38:25.95Z" },
- { url = "https://files.pythonhosted.org/packages/fc/1e/9721930762fb042ea12b4d273a0729be91922adfbe4746552b8b28b645bc/frozenlist-1.6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1db8b2fc7ee8a940b547a14c10e56560ad3ea6499dc6875c354e2335812f739d", size = 308229, upload-time = "2025-04-17T22:38:28.081Z" },
- { url = "https://files.pythonhosted.org/packages/78/04/48b128738e2a808e5ea9af2bcbe01bdb76a29663f5327df80a14103baf23/frozenlist-1.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4da6fc43048b648275a220e3a61c33b7fff65d11bdd6dcb9d9c145ff708b804c", size = 279689, upload-time = "2025-04-17T22:38:30.371Z" },
- { url = "https://files.pythonhosted.org/packages/62/9d/97b06744871c0d5d6e7a3873cfe9884d46d6792b630f99abc8526e908486/frozenlist-1.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef8e7e8f2f3820c5f175d70fdd199b79e417acf6c72c5d0aa8f63c9f721646f", size = 288640, upload-time = "2025-04-17T22:38:32.051Z" },
- { url = "https://files.pythonhosted.org/packages/95/13/e4def76c11b2c7b73b63bc47b848a94f6de1751a665bfeb58478553846df/frozenlist-1.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aa733d123cc78245e9bb15f29b44ed9e5780dc6867cfc4e544717b91f980af3b", size = 292169, upload-time = "2025-04-17T22:38:34.15Z" },
- { url = "https://files.pythonhosted.org/packages/4b/d4/b6428f7774ccd0cc4882de0200df04446b69ea5e12c9a9e06a0478ae17ce/frozenlist-1.6.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ba7f8d97152b61f22d7f59491a781ba9b177dd9f318486c5fbc52cde2db12189", size = 306172, upload-time = "2025-04-17T22:38:35.938Z" },
- { url = "https://files.pythonhosted.org/packages/ec/78/14e42aa004f634b40d97715a7c8597ba0d41caa46837899a03b800e48eda/frozenlist-1.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:56a0b8dd6d0d3d971c91f1df75e824986667ccce91e20dca2023683814344791", size = 287203, upload-time = "2025-04-17T22:38:38.133Z" },
- { url = "https://files.pythonhosted.org/packages/b1/f2/40525c3c486da199e9bd6292a4269c9aa2f48b692c6e39da7967dab92058/frozenlist-1.6.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5c9e89bf19ca148efcc9e3c44fd4c09d5af85c8a7dd3dbd0da1cb83425ef4983", size = 306991, upload-time = "2025-04-17T22:38:39.884Z" },
- { url = "https://files.pythonhosted.org/packages/4b/2f/d48b888d6941b20305c78da3fc37d112b00b1711ba397d186d481198bb21/frozenlist-1.6.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1330f0a4376587face7637dfd245380a57fe21ae8f9d360c1c2ef8746c4195fa", size = 309692, upload-time = "2025-04-17T22:38:42.164Z" },
- { url = "https://files.pythonhosted.org/packages/b4/a1/bb8ed90733b73611f1f9f114b65f9d11de66b037e7208a7a16977cd6d3ab/frozenlist-1.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2187248203b59625566cac53572ec8c2647a140ee2738b4e36772930377a533c", size = 296256, upload-time = "2025-04-17T22:38:46.453Z" },
- { url = "https://files.pythonhosted.org/packages/ba/50/2210d332234b02ce0f0d8360034e0ceada6e348a83d8fa924f418ae3b58c/frozenlist-1.6.0-cp39-cp39-win32.whl", hash = "sha256:2b8cf4cfea847d6c12af06091561a89740f1f67f331c3fa8623391905e878530", size = 115751, upload-time = "2025-04-17T22:38:48.555Z" },
- { url = "https://files.pythonhosted.org/packages/8c/a2/15db0eef508761c5f7c669b70ed4ec81af4d8ddad86d1b6ef9d6746a56b4/frozenlist-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:1255d5d64328c5a0d066ecb0f02034d086537925f1f04b50b1ae60d37afbf572", size = 120975, upload-time = "2025-04-17T22:38:50.213Z" },
- { url = "https://files.pythonhosted.org/packages/71/3e/b04a0adda73bd52b390d730071c0d577073d3d26740ee1bad25c3ad0f37b/frozenlist-1.6.0-py3-none-any.whl", hash = "sha256:535eec9987adb04701266b92745d6cdcef2e77669299359c3009c3404dd5d191", size = 12404, upload-time = "2025-04-17T22:38:51.668Z" },
+resolution-markers = [
+ "python_full_version >= '3.14'",
+ "python_full_version >= '3.10' and python_full_version < '3.14'",
+]
+dependencies = [
+ { name = "flask", marker = "python_full_version >= '3.10'" },
+ { name = "limits", version = "5.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "ordered-set", marker = "python_full_version >= '3.10'" },
+ { name = "rich", version = "14.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "typing-extensions", marker = "python_full_version >= '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/53/13ccac4772f7efd58e4b8308e2b8bfaef3a45a4420ec43966f2dbff904c8/flask_limiter-4.0.0.tar.gz", hash = "sha256:536a8df0bb2033f415a2212e19a3b7ddfea38585ac5a2444e1cfa986a697847c", size = 384407, upload-time = "2025-09-30T21:22:35.087Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4e/44/25ebda35a714d79c085d3f3c2073d4eb5b70d2ed8794134e2c902128d60f/flask_limiter-4.0.0-py3-none-any.whl", hash = "sha256:be62b462d5a052d21572d4c932e18a8da58cf9ddc18a34b6f1c21fa2ec35a395", size = 29896, upload-time = "2025-09-30T21:22:33.261Z" },
+]
+
+[package.optional-dependencies]
+mongodb = [
+ { name = "limits", version = "5.6.0", source = { registry = "https://pypi.org/simple" }, extra = ["mongodb"], marker = "python_full_version >= '3.10'" },
+]
+
+[[package]]
+name = "frozenlist"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" },
+ { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" },
+ { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" },
+ { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" },
+ { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" },
+ { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" },
+ { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" },
+ { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" },
+ { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" },
+ { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" },
+ { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" },
+ { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" },
+ { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" },
+ { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" },
+ { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
+ { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
+ { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
+ { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
+ { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
+ { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
+ { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
+ { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
+ { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
+ { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
+ { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
+ { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
+ { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
+ { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
+ { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
+ { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
+ { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
+ { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
+ { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
+ { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/59/ae5cdac87a00962122ea37bb346d41b66aec05f9ce328fa2b9e216f8967b/frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47", size = 86967, upload-time = "2025-10-06T05:37:55.607Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/10/17059b2db5a032fd9323c41c39e9d1f5f9d0c8f04d1e4e3e788573086e61/frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca", size = 49984, upload-time = "2025-10-06T05:37:57.049Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/de/ad9d82ca8e5fa8f0c636e64606553c79e2b859ad253030b62a21fe9986f5/frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068", size = 50240, upload-time = "2025-10-06T05:37:58.145Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/45/3dfb7767c2a67d123650122b62ce13c731b6c745bc14424eea67678b508c/frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95", size = 219472, upload-time = "2025-10-06T05:37:59.239Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/bf/5bf23d913a741b960d5c1dac7c1985d8a2a1d015772b2d18ea168b08e7ff/frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459", size = 221531, upload-time = "2025-10-06T05:38:00.521Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/03/27ec393f3b55860859f4b74cdc8c2a4af3dbf3533305e8eacf48a4fd9a54/frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675", size = 219211, upload-time = "2025-10-06T05:38:01.842Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/ad/0fd00c404fa73fe9b169429e9a972d5ed807973c40ab6b3cf9365a33d360/frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61", size = 231775, upload-time = "2025-10-06T05:38:03.384Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/c3/86962566154cb4d2995358bc8331bfc4ea19d07db1a96f64935a1607f2b6/frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6", size = 236631, upload-time = "2025-10-06T05:38:04.609Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/9e/6ffad161dbd83782d2c66dc4d378a9103b31770cb1e67febf43aea42d202/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5", size = 218632, upload-time = "2025-10-06T05:38:05.917Z" },
+ { url = "https://files.pythonhosted.org/packages/58/b2/4677eee46e0a97f9b30735e6ad0bf6aba3e497986066eb68807ac85cf60f/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3", size = 235967, upload-time = "2025-10-06T05:38:07.614Z" },
+ { url = "https://files.pythonhosted.org/packages/05/f3/86e75f8639c5a93745ca7addbbc9de6af56aebb930d233512b17e46f6493/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1", size = 228799, upload-time = "2025-10-06T05:38:08.845Z" },
+ { url = "https://files.pythonhosted.org/packages/30/00/39aad3a7f0d98f5eb1d99a3c311215674ed87061aecee7851974b335c050/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178", size = 230566, upload-time = "2025-10-06T05:38:10.52Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/4d/aa144cac44568d137846ddc4d5210fb5d9719eb1d7ec6fa2728a54b5b94a/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda", size = 217715, upload-time = "2025-10-06T05:38:11.832Z" },
+ { url = "https://files.pythonhosted.org/packages/64/4c/8f665921667509d25a0dd72540513bc86b356c95541686f6442a3283019f/frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087", size = 39933, upload-time = "2025-10-06T05:38:13.061Z" },
+ { url = "https://files.pythonhosted.org/packages/79/bd/bcc926f87027fad5e59926ff12d136e1082a115025d33c032d1cd69ab377/frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a", size = 44121, upload-time = "2025-10-06T05:38:14.572Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/07/9c2e4eb7584af4b705237b971b89a4155a8e57599c4483a131a39256a9a0/frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103", size = 40312, upload-time = "2025-10-06T05:38:15.699Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
[[package]]
@@ -539,7 +935,8 @@ name = "gunicorn"
version = "23.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "packaging" },
+ { name = "packaging", version = "24.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "packaging", version = "25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
wheels = [
@@ -548,11 +945,11 @@ wheels = [
[[package]]
name = "idna"
-version = "3.10"
+version = "3.11"
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, upload-time = "2024-09-15T18:07:39.745Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
@@ -560,7 +957,7 @@ name = "importlib-metadata"
version = "8.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "zipp" },
+ { name = "zipp", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" }
wheels = [
@@ -571,11 +968,27 @@ wheels = [
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.10'",
+]
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14'",
+ "python_full_version >= '3.10' and python_full_version < '3.14'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
[[package]]
name = "itsdangerous"
version = "2.2.0"
@@ -601,10 +1014,13 @@ wheels = [
name = "limits"
version = "4.2"
source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.10'",
+]
dependencies = [
- { name = "deprecated" },
- { name = "packaging" },
- { name = "typing-extensions" },
+ { name = "deprecated", marker = "python_full_version < '3.10'" },
+ { name = "packaging", version = "24.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7b/54/f73e4810332f500b46b3fbf01d3258e0cbf1508d1be8d2438a1e174208c3/limits-4.2.tar.gz", hash = "sha256:d602ceae5d6b71063d5f9338904e32d569efaa84a7dd0399cde7ca6ff1a8fc9b", size = 85710, upload-time = "2025-03-11T18:55:13.637Z" }
wheels = [
@@ -613,183 +1029,256 @@ wheels = [
[package.optional-dependencies]
mongodb = [
- { name = "pymongo" },
+ { name = "pymongo", marker = "python_full_version < '3.10'" },
+]
+
+[[package]]
+name = "limits"
+version = "5.6.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14'",
+ "python_full_version >= '3.10' and python_full_version < '3.14'",
+]
+dependencies = [
+ { name = "deprecated", marker = "python_full_version >= '3.10'" },
+ { name = "packaging", version = "25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "typing-extensions", marker = "python_full_version >= '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bb/e5/c968d43a65128cd54fb685f257aafb90cd5e4e1c67d084a58f0e4cbed557/limits-5.6.0.tar.gz", hash = "sha256:807fac75755e73912e894fdd61e2838de574c5721876a19f7ab454ae1fffb4b5", size = 182984, upload-time = "2025-09-29T17:15:22.689Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/96/4fcd44aed47b8fcc457653b12915fcad192cd646510ef3f29fd216f4b0ab/limits-5.6.0-py3-none-any.whl", hash = "sha256:b585c2104274528536a5b68864ec3835602b3c4a802cd6aa0b07419798394021", size = 60604, upload-time = "2025-09-29T17:15:18.419Z" },
+]
+
+[package.optional-dependencies]
+mongodb = [
+ { name = "pymongo", marker = "python_full_version >= '3.10'" },
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.10'",
+]
dependencies = [
- { name = "mdurl" },
+ { name = "mdurl", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
]
+[[package]]
+name = "markdown-it-py"
+version = "4.0.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14'",
+ "python_full_version >= '3.10' and python_full_version < '3.14'",
+]
+dependencies = [
+ { name = "mdurl", marker = "python_full_version >= '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
+]
+
[[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, upload-time = "2024-10-18T15:21:54.129Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload-time = "2024-10-18T15:20:51.44Z" },
- { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload-time = "2024-10-18T15:20:52.426Z" },
- { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload-time = "2024-10-18T15:20:53.578Z" },
- { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload-time = "2024-10-18T15:20:55.06Z" },
- { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload-time = "2024-10-18T15:20:55.906Z" },
- { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload-time = "2024-10-18T15:20:57.189Z" },
- { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload-time = "2024-10-18T15:20:58.235Z" },
- { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload-time = "2024-10-18T15:20:59.235Z" },
- { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload-time = "2024-10-18T15:21:00.307Z" },
- { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload-time = "2024-10-18T15:21:01.122Z" },
- { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" },
- { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" },
- { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" },
- { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" },
- { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" },
- { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" },
- { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" },
- { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" },
- { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" },
- { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" },
- { 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, upload-time = "2024-10-18T15:21:13.777Z" },
- { 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, upload-time = "2024-10-18T15:21:14.822Z" },
- { 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, upload-time = "2024-10-18T15:21:15.642Z" },
- { 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, upload-time = "2024-10-18T15:21:17.133Z" },
- { 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, upload-time = "2024-10-18T15:21:18.064Z" },
- { 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, upload-time = "2024-10-18T15:21:18.859Z" },
- { 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, upload-time = "2024-10-18T15:21:19.671Z" },
- { 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, upload-time = "2024-10-18T15:21:20.971Z" },
- { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" },
- { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" },
- { 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, upload-time = "2024-10-18T15:21:24.577Z" },
- { 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, upload-time = "2024-10-18T15:21:25.382Z" },
- { 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, upload-time = "2024-10-18T15:21:26.199Z" },
- { 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, upload-time = "2024-10-18T15:21:27.029Z" },
- { 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, upload-time = "2024-10-18T15:21:27.846Z" },
- { 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, upload-time = "2024-10-18T15:21:28.744Z" },
- { 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, upload-time = "2024-10-18T15:21:29.545Z" },
- { 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, upload-time = "2024-10-18T15:21:30.366Z" },
- { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
- { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
- { 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, upload-time = "2024-10-18T15:21:33.625Z" },
- { 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, upload-time = "2024-10-18T15:21:34.611Z" },
- { 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, upload-time = "2024-10-18T15:21:35.398Z" },
- { 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, upload-time = "2024-10-18T15:21:36.231Z" },
- { 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, upload-time = "2024-10-18T15:21:37.073Z" },
- { 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, upload-time = "2024-10-18T15:21:37.932Z" },
- { 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, upload-time = "2024-10-18T15:21:39.799Z" },
- { 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, upload-time = "2024-10-18T15:21:40.813Z" },
- { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
- { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
- { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344, upload-time = "2024-10-18T15:21:43.721Z" },
- { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389, upload-time = "2024-10-18T15:21:44.666Z" },
- { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607, upload-time = "2024-10-18T15:21:45.452Z" },
- { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728, upload-time = "2024-10-18T15:21:46.295Z" },
- { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826, upload-time = "2024-10-18T15:21:47.134Z" },
- { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843, upload-time = "2024-10-18T15:21:48.334Z" },
- { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219, upload-time = "2024-10-18T15:21:49.587Z" },
- { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946, upload-time = "2024-10-18T15:21:50.441Z" },
- { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063, upload-time = "2024-10-18T15:21:51.385Z" },
- { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506, upload-time = "2024-10-18T15:21:52.974Z" },
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
+ { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
+ { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
+ { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
+ { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
+ { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
+ { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
+ { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
+ { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
+ { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
+ { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+ { url = "https://files.pythonhosted.org/packages/56/23/0d8c13a44bde9154821586520840643467aee574d8ce79a17da539ee7fed/markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", size = 11623, upload-time = "2025-09-27T18:37:29.296Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/23/07a2cb9a8045d5f3f0890a8c3bc0859d7a47bfd9a560b563899bec7b72ed/markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", size = 12049, upload-time = "2025-09-27T18:37:30.234Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/e4/6be85eb81503f8e11b61c0b6369b6e077dcf0a74adbd9ebf6b349937b4e9/markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", size = 21923, upload-time = "2025-09-27T18:37:31.177Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/bc/4dc914ead3fe6ddaef035341fee0fc956949bbd27335b611829292b89ee2/markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42", size = 20543, upload-time = "2025-09-27T18:37:32.168Z" },
+ { url = "https://files.pythonhosted.org/packages/89/6e/5fe81fbcfba4aef4093d5f856e5c774ec2057946052d18d168219b7bd9f9/markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b", size = 20585, upload-time = "2025-09-27T18:37:33.166Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/f6/e0e5a3d3ae9c4020f696cd055f940ef86b64fe88de26f3a0308b9d3d048c/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758", size = 21387, upload-time = "2025-09-27T18:37:34.185Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/25/651753ef4dea08ea790f4fbb65146a9a44a014986996ca40102e237aa49a/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2", size = 20133, upload-time = "2025-09-27T18:37:35.138Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/0a/c3cf2b4fef5f0426e8a6d7fce3cb966a17817c568ce59d76b92a233fdbec/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d", size = 20588, upload-time = "2025-09-27T18:37:36.096Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/1b/a7782984844bd519ad4ffdbebbba2671ec5d0ebbeac34736c15fb86399e8/markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7", size = 14566, upload-time = "2025-09-27T18:37:37.09Z" },
+ { url = "https://files.pythonhosted.org/packages/18/1f/8d9c20e1c9440e215a44be5ab64359e207fcb4f675543f1cf9a2a7f648d0/markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e", size = 15053, upload-time = "2025-09-27T18:37:38.054Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" },
]
[[package]]
name = "maxminddb"
-version = "2.7.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d1/10/7a7cf5219b74b19ea1834b43256e114564e8a845f447446ac821e1b9951e/maxminddb-2.7.0.tar.gz", hash = "sha256:23a715ed3b3aed07adae4beeed06c51fd582137b5ae13d3c6e5ca4890f70ebbf", size = 196583, upload-time = "2025-05-05T19:31:43.957Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/69/da/032781189e13f39726794663a025c04680cd8657adaf39ecf811bb163179/maxminddb-2.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:89b12a3af361f22d6e5a5949e8d224ff06dc5f5f49d9be85c3c99d95e33234ca", size = 35130, upload-time = "2025-05-05T19:29:38.237Z" },
- { url = "https://files.pythonhosted.org/packages/96/97/1e35d9d2fe6322330e84c42c74d67577e64ae74eaf34131bc16ca06ad1f0/maxminddb-2.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1015f866e7768fb3eb63e08f152494f5152d0ba50ef4d8332ccbaffeee7c2111", size = 34941, upload-time = "2025-05-05T19:29:39.679Z" },
- { url = "https://files.pythonhosted.org/packages/fe/86/b29c05b2f32fccec7bd728005ff146bc2b9637ad33e73c86ac1a33b5c875/maxminddb-2.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55eac03e6fcdec92a9f01e7812a1da6cc4b1fa94c8af714317dd21fcbefbb732", size = 88660, upload-time = "2025-05-05T19:29:41.378Z" },
- { url = "https://files.pythonhosted.org/packages/ed/8a/01ca8134a5eecbd084f9f1967ede42410acd7f680b549b977c39602e3d06/maxminddb-2.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f630c7a540ed75f393cf76bf0702f4564040217adb1c777df40ef4d241587e04", size = 93386, upload-time = "2025-05-05T19:29:42.583Z" },
- { url = "https://files.pythonhosted.org/packages/ae/9a/c9e56957377174718b229f4d71325d08ea394820a8db9a25985e08c3c40b/maxminddb-2.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4a94a96696c9d17d5c84d6f7b1bca4fbda4ab666563c4669c4ca483f4bcbb563", size = 89708, upload-time = "2025-05-05T19:29:43.886Z" },
- { url = "https://files.pythonhosted.org/packages/78/c2/142c7ee80bac6b963cb7af4babd103ba7e5932c7159e91ccb6de5b334621/maxminddb-2.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96717032bd15dcf2fd77da74d49ddf35847aae6cfd8cf3b2b1847ddb356e3e29", size = 87981, upload-time = "2025-05-05T19:29:45.098Z" },
- { url = "https://files.pythonhosted.org/packages/36/48/d1c27756833b35fb0f6c5b99eedfb34d27f90037cbd4f29da21718e21b1d/maxminddb-2.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f7c29ec728a82a0cd4d4ece6752fc3a8df2f3ff967ee35bdaf7a4a10b55016f", size = 86562, upload-time = "2025-05-05T19:29:46.363Z" },
- { url = "https://files.pythonhosted.org/packages/f7/d3/ffe2076110d3839bd673d00515f134a6537d80b23ad9467e09b37832a9d3/maxminddb-2.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c9b5d8c433479b1d40fec45e87c22873e7d6d17310981fafcf5823759c83f0d", size = 92196, upload-time = "2025-05-05T19:29:47.453Z" },
- { url = "https://files.pythonhosted.org/packages/86/d8/6fa7b5d00a97f3b22b16da5447769c6c6ed5c0ee9bc6b062d6dd0a9e01c4/maxminddb-2.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed1a8db359ad726e0cea25fd6e6f22245bfa6f5ab7bedb131f0bb18b01ed3474", size = 90455, upload-time = "2025-05-05T19:29:48.963Z" },
- { url = "https://files.pythonhosted.org/packages/58/34/59487a5061321e9b716824daed07380a0ae946191383dd606c8f9319f724/maxminddb-2.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:2ae9aeeab1e1ed9a531ff1a78f7d873fe38614018223fe4b6bbde1a3a89c3f52", size = 97472, upload-time = "2025-05-05T19:29:50.106Z" },
- { url = "https://files.pythonhosted.org/packages/1c/08/2bf0ba3718687fa85664a7cfae18adbb0cf2dac8f3403c59f0b453ee5f64/maxminddb-2.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:307b7080d123cfc0f90851fca127421b0222ca973bd8e878161449e4a1185ef3", size = 94773, upload-time = "2025-05-05T19:29:51.241Z" },
- { url = "https://files.pythonhosted.org/packages/45/8f/2de6fbb2ff182ce26671f301c58b3307ed8858e3e79da58543498eab8dc3/maxminddb-2.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c86601ea1ea6d6075b45a5a95f888c39a17fa077590b812a39d74835930f6612", size = 90362, upload-time = "2025-05-05T19:29:52.531Z" },
- { url = "https://files.pythonhosted.org/packages/e1/2d/89c65ecac087e94f3c2c111b9d8c9171f35eb3a23e5a8049e72856748ec7/maxminddb-2.7.0-cp310-cp310-win32.whl", hash = "sha256:f1a4a533f8cf84f52ca3e2f07e0190daa6c6e22300f47631cb9c6d8cc2ac0325", size = 34553, upload-time = "2025-05-05T19:29:53.739Z" },
- { url = "https://files.pythonhosted.org/packages/f7/f6/cbcf2a794bc22519e1d573254805cb1c3562bde5525df74bf3e3d8110bbc/maxminddb-2.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:17662f4e63c269ae2a3fc74e4e93e8d99d3f4a1b080fb437107a4a57bccb1fe3", size = 36607, upload-time = "2025-05-05T19:29:54.768Z" },
- { url = "https://files.pythonhosted.org/packages/ba/01/dac2c5e18e650074886805459f4e4a6bd60528c7a05650d46e3110d1ebb0/maxminddb-2.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d91869297c8b63a1023c335063eb62046ad039b82c419c7074d9aeb89c249b2", size = 35126, upload-time = "2025-05-05T19:29:56.525Z" },
- { url = "https://files.pythonhosted.org/packages/07/1d/f23ad73055a31b11a5aea843bcdb4f3881b5f7c7cc67e26e6b9d05897d38/maxminddb-2.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4a017cb6253d2c3c90f1b5408ad4998fe0e9674594251491094dafa49c251afd", size = 34934, upload-time = "2025-05-05T19:29:57.567Z" },
- { url = "https://files.pythonhosted.org/packages/0f/8f/1ab32fbb90955ccbe5ca8d5f8373b6f9c3c8cbac326d10727ad2b11acc6b/maxminddb-2.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab498cc58d80b8fcd61a0aea9fd37c7ef5e56893002a43133cc2983f3a0ec2ac", size = 88892, upload-time = "2025-05-05T19:29:58.618Z" },
- { url = "https://files.pythonhosted.org/packages/11/96/437095cd900c40d945d89f61c2f77bbba91d203441074d490559ed433981/maxminddb-2.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ecbdb5383044281e1b3e5f7712783d87d7560040018ed06ea7c3404dcbebdfa", size = 93584, upload-time = "2025-05-05T19:29:59.808Z" },
- { url = "https://files.pythonhosted.org/packages/ec/8a/3385dbea5155e6867091e241aedc131b1489198c6e93568227f04a9e5594/maxminddb-2.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40dd9780fd564d517eb412e4b56e11ea10483ff6b73258e333f9ca0f1f4386e4", size = 89910, upload-time = "2025-05-05T19:30:01.11Z" },
- { url = "https://files.pythonhosted.org/packages/cb/11/fca28f795312cf978acdd21f1c491b3ffa554cc5b13c09d43bb9bc84c524/maxminddb-2.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e35cb54d0eb2e2fa7a44021ade8c77d89589a64d797e292f24594a7696eee1c5", size = 88194, upload-time = "2025-05-05T19:30:02.25Z" },
- { url = "https://files.pythonhosted.org/packages/7d/55/036bd67035e09dd530d03f19287945146f396dc01416b0fcfb5a1f5b30c8/maxminddb-2.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4bc2c5d925b6b95634a71b9a1083bb193e7ecf32416679a5d87b31acfba7c5d", size = 86782, upload-time = "2025-05-05T19:30:03.424Z" },
- { url = "https://files.pythonhosted.org/packages/9f/bb/926017f4e12cbf0ba14d9ec1ecf77841289aa65d11853ffa801ab1e335cd/maxminddb-2.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc2d4f58f12831ca9ceeb6689f331dee443b82f9fc472bc036a9c083981cc587", size = 92403, upload-time = "2025-05-05T19:30:04.654Z" },
- { url = "https://files.pythonhosted.org/packages/46/b3/17d5d8d633d071a0f4b8b9e546618055c507bfbaaa91a37ac3334efc9096/maxminddb-2.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:053fa4416a9735bdd890c0e349e4a4c54ac2841077fb508878dff0c27d60a130", size = 90600, upload-time = "2025-05-05T19:30:06.062Z" },
- { url = "https://files.pythonhosted.org/packages/4a/f2/a2660b4f0eca46f28b6637e67bf11fb9b841f0cc4a9e39030594be1efd47/maxminddb-2.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8cdec5dfb0c6308ca28b144aa70818a8dd96f1d0f9127ca46d68b93e63425e2f", size = 97659, upload-time = "2025-05-05T19:30:07.172Z" },
- { url = "https://files.pythonhosted.org/packages/1b/43/09e092a663ee8ed07837289b41423ec82268bcfccfc8b788c4df52a990a5/maxminddb-2.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:038d929871998f897ef51ac9cf107ee029ce434e4c6cfaced9149ff31a3c25fd", size = 94940, upload-time = "2025-05-05T19:30:09.009Z" },
- { url = "https://files.pythonhosted.org/packages/18/4d/3a0a7e0f6d51dca77b80aee872fbfea6ab13ed824f8bd5c4f17f52aba1ef/maxminddb-2.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b1683100ac8c009c7969310e544c7bb703186a6cf6eb76a0e96a23c647a6b164", size = 90533, upload-time = "2025-05-05T19:30:10.332Z" },
- { url = "https://files.pythonhosted.org/packages/06/e9/74639bdd66863b0ea59d03c80c5252f76a480318a48fcbc33b4073089428/maxminddb-2.7.0-cp311-cp311-win32.whl", hash = "sha256:78be0520ca048834d7bbc1d30147acc70f7e1bca91a6edfc90cab07467cad368", size = 34545, upload-time = "2025-05-05T19:30:11.467Z" },
- { url = "https://files.pythonhosted.org/packages/0e/66/9a4dfcc39c9e59995123f6a1632b84c60d7a3922d585aed839e731a20602/maxminddb-2.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:bdc36f47b1231b6769d32ab5019b7d1a5d0f43a99015b8d6989a23bbfb01e79f", size = 36610, upload-time = "2025-05-05T19:30:12.51Z" },
- { url = "https://files.pythonhosted.org/packages/76/ed/f4e3fd93245b9c0df51dfdb39c6c1280aa64abbc4ea55a1131013de9bb11/maxminddb-2.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:335e0ab48b3962991f0d238e6d1875ca121ebfb43449b20bd5e77561f69f2ac2", size = 35331, upload-time = "2025-05-05T19:30:14.678Z" },
- { url = "https://files.pythonhosted.org/packages/98/60/3af97e4517aebdeabae70613a1e03f9e276f0f04c59e93cfe16ab157991c/maxminddb-2.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:22ec500b99ba161bd97daec745d7cdd5f6a38a7d7e076045fef5e4ce8a76a176", size = 35108, upload-time = "2025-05-05T19:30:15.731Z" },
- { url = "https://files.pythonhosted.org/packages/c9/82/9c46df3d7ab2b7da9251255119fe3788d1c55e439232cd5fbfdc8fd8d818/maxminddb-2.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d5676a7b9f4dd5780709bc3937a0a860224b9cda42e01d1bb4e31c5ed5f7599", size = 90189, upload-time = "2025-05-05T19:30:16.847Z" },
- { url = "https://files.pythonhosted.org/packages/3d/dd/7191df67c4424ae1c64df769628297c80d77a6950c134de09267877e7d9b/maxminddb-2.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6a7b4a7d563e4e9bebc87678f818e6d83134d80f2bf4a43392ffcbd6d7775b4", size = 94710, upload-time = "2025-05-05T19:30:18.485Z" },
- { url = "https://files.pythonhosted.org/packages/ea/f6/1be30223b958032cf19424c0ae1765cd15deb6ddf296a7a118103f277324/maxminddb-2.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:705c9110ed76ff1fccef120a0974ef2fa577f8f5d4d30235b7d29416295c626d", size = 91334, upload-time = "2025-05-05T19:30:20.083Z" },
- { url = "https://files.pythonhosted.org/packages/4e/87/29dbba2b167c191950a711c0a2407860ac6a808466bc354a12be5509e30a/maxminddb-2.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a9b2391ade3fecf2716fba27ed50982a6cb54986039496d454917dfd416098e", size = 89681, upload-time = "2025-05-05T19:30:21.191Z" },
- { url = "https://files.pythonhosted.org/packages/cf/e3/405791b96c54b06eadfb20ceaf8dd7672cdbceec95452a0de63681563165/maxminddb-2.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78be13a164fa09743734d39873003e734a11b318707508a8934c53f2b4ad6f03", size = 87764, upload-time = "2025-05-05T19:30:22.434Z" },
- { url = "https://files.pythonhosted.org/packages/9a/65/4be42092baed35be8702e684aad9bb0ecd593046d55c654b5b0e063a9e15/maxminddb-2.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:14c5aaecbeadacb19856593e833aab8318a7ee693aa4fde777286330cf8f23a8", size = 93371, upload-time = "2025-05-05T19:30:24.122Z" },
- { url = "https://files.pythonhosted.org/packages/2e/96/83565cc15df7e57aae85db5c9d5447b3ad1706422a92fb54b1414bd7db56/maxminddb-2.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:97faaf488036fa9403a6a882f09a50f3eaf3855245fff707ff960d3fb1e1ac70", size = 91696, upload-time = "2025-05-05T19:30:25.793Z" },
- { url = "https://files.pythonhosted.org/packages/60/fa/f40949e7c63d306ad49d9dbe268d76e6cdf9bc0034a413eeef93e12a7fad/maxminddb-2.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843ff9d8524ae01f66576f057bb8b05a889b4587f32b1da4f7fc3cf366026497", size = 98415, upload-time = "2025-05-05T19:30:27.499Z" },
- { url = "https://files.pythonhosted.org/packages/78/6e/944910118d45dd2ded272010c433a72db8cda58d49b6b4def8e02e39b548/maxminddb-2.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:6f4880e6aeb55cfab6bde170e3a99b9e71c7a660895032d32ccd40101eeed9a8", size = 96262, upload-time = "2025-05-05T19:30:29.761Z" },
- { url = "https://files.pythonhosted.org/packages/9f/7f/8490a8b08d9faa14623c102266126161dd0bbe681232e746b1363895963f/maxminddb-2.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5e225d892c88ce1c844c27881143555fda954bdc8414e0e2a3873d6717e92295", size = 92188, upload-time = "2025-05-05T19:30:30.896Z" },
- { url = "https://files.pythonhosted.org/packages/16/31/21a1e2ca565a548c440e52f70de5740e73c8e0811c622c10a0ce356b1420/maxminddb-2.7.0-cp312-cp312-win32.whl", hash = "sha256:4e9126343edc503233900783bd97cb210b1be740e8c99a5f03484275b8a072cb", size = 34695, upload-time = "2025-05-05T19:30:32.489Z" },
- { url = "https://files.pythonhosted.org/packages/44/5e/3df632ff3752770cbf74ef024a0b5b3746762a6d9e0c11aa4c825e95cac1/maxminddb-2.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cdeb12a98cf4d9d6e4b1496f0381745c7a028368e9085ad2a6fee5300e9097f", size = 36720, upload-time = "2025-05-05T19:30:34.084Z" },
- { url = "https://files.pythonhosted.org/packages/8f/0e/68a558e11e8a2aaeb1b28be27c784052dcccd780175fa9e3d2693274e8d6/maxminddb-2.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2328575e2d2ab6179acf93c09745e9af10eb92aaa305cb5bd0f7c307d0dd398e", size = 35328, upload-time = "2025-05-05T19:30:35.134Z" },
- { url = "https://files.pythonhosted.org/packages/75/ff/2c98dda0d0aaa09dbe9a4030bd9ab056e0bf6c6559215e34185e2fd62d50/maxminddb-2.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c4f71a72f3dbdc2abd58c36ad0ad4bd936781354feee8538614d2170223675f0", size = 35098, upload-time = "2025-05-05T19:30:36.259Z" },
- { url = "https://files.pythonhosted.org/packages/9a/cb/99d1650daa24a9acb55c81412fcefa5d95b7e80a872876a902e14f33ec4d/maxminddb-2.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:566e7ea8296ad126a24df52e6c37382dc9660c414ceea4c4c687bbca2d522c28", size = 90193, upload-time = "2025-05-05T19:30:37.325Z" },
- { url = "https://files.pythonhosted.org/packages/c0/15/53ceb43e1e1e7493a66fb9a3b2d3248198316d2dbe746c585591276f1aad/maxminddb-2.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c10d94df0d5ea22873a5dc1af24d8972de0a22841dbd90a7e450f66a6f11ed21", size = 94695, upload-time = "2025-05-05T19:30:38.528Z" },
- { url = "https://files.pythonhosted.org/packages/2c/d7/e26d168d85e2503232d5df2a847641024afd11405fd5132816728cc9e399/maxminddb-2.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7f0e9a4db3c986f208dd4359e9d9e776e28ce8aae540da6f1a733fae3bb67ac", size = 91306, upload-time = "2025-05-05T19:30:40.078Z" },
- { url = "https://files.pythonhosted.org/packages/b5/b5/4bb9330ee29efb0c515cb8c6c500f367021c163ebb81380789e6ac846f8b/maxminddb-2.7.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef41949246035af8cb5970bee2e94bbc894203312fd6fb55cbd4fe30c6e44374", size = 89620, upload-time = "2025-05-05T19:30:41.279Z" },
- { url = "https://files.pythonhosted.org/packages/72/e5/1335e42615b57fd821a6c606119cb4babd85bc88839f8dbae0b5bb082d04/maxminddb-2.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:531be1066697b57928bce2ac9cb7e705b8cebdfa2e42dfbebc92b75fc53ad22f", size = 87751, upload-time = "2025-05-05T19:30:42.464Z" },
- { url = "https://files.pythonhosted.org/packages/b2/9a/af47d3f7a15a49be61315f29bb0e232c1f5040f3afc685509cee1ebdaef7/maxminddb-2.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:265e938c12628fceb71665e28bfca206ee9d8ae6ac18282cbfc544753ccc8b9b", size = 93328, upload-time = "2025-05-05T19:30:43.697Z" },
- { url = "https://files.pythonhosted.org/packages/2f/43/ecda2cc5a7ffae034692374401f97c3ef8fe15f22826ae2784b38ecf0cfd/maxminddb-2.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7b101cf6b79db4c046c9c9b157bb9730308074749c442f50d52a7a0e5d765357", size = 91701, upload-time = "2025-05-05T19:30:44.83Z" },
- { url = "https://files.pythonhosted.org/packages/3c/14/8edd17bdeaddd9d7d138008d6fc14baacdda418a5346d09215d14870dfd2/maxminddb-2.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:faecf825f812d54e1cb053e75358656b280af1ea4b6f53b3f1a98c3f9fa41a46", size = 98466, upload-time = "2025-05-05T19:30:46.041Z" },
- { url = "https://files.pythonhosted.org/packages/d4/6f/cefabf7868b5406f4df04c3f4cb95dc802c1ad1b05f26046a4584a235268/maxminddb-2.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dd266b3060bb6b6b05009b04ca93787fab0a00f16638827d34bab50cfdf68dd4", size = 96255, upload-time = "2025-05-05T19:30:47.785Z" },
- { url = "https://files.pythonhosted.org/packages/6f/4a/f604c942dc9d3e755601831ee101198d3090c0dfa5485e2aaf3245075bf3/maxminddb-2.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f30bdd4c618c372c0f4f981f2241aad8e3ab9c361bb1d299f213e9a4c2a3fd8", size = 92190, upload-time = "2025-05-05T19:30:49.052Z" },
- { url = "https://files.pythonhosted.org/packages/ba/6f/563058bc28704b24fa04830acc40abca03debcaa2a12195d8269b490475b/maxminddb-2.7.0-cp313-cp313-win32.whl", hash = "sha256:023f23654b38345965cab3e33465a4b82edb2250ba7c6db5c175a872645c35c5", size = 34699, upload-time = "2025-05-05T19:30:50.202Z" },
- { url = "https://files.pythonhosted.org/packages/e8/84/33e0389d97ca9bc7e902c1f4a74e626349043c942ba0b6458fa96cbea0a8/maxminddb-2.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:f81d678ab25d4867f95fb44cce3c67f6157d25dc8846191fd4eb0e38f49a263f", size = 36723, upload-time = "2025-05-05T19:30:51.799Z" },
- { url = "https://files.pythonhosted.org/packages/70/92/e544cb30f0bbb53fa08646b3f600397fcca6b5bd26973c0e7bb5515a88a9/maxminddb-2.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:68a12bd637b827ec2182ce5ab2ba0baf5555a70595d7aa5a9a92860a2c61ecae", size = 35129, upload-time = "2025-05-05T19:30:53.509Z" },
- { url = "https://files.pythonhosted.org/packages/3b/33/56416bcbbc76bff34944e98b7bae427b778dde404147b85fe29e7ac216d5/maxminddb-2.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fad42dbedad9339c6894f2dc6fda0a421d6ca45c7e846fef3114f646a5ecdeae", size = 34940, upload-time = "2025-05-05T19:30:55.121Z" },
- { url = "https://files.pythonhosted.org/packages/44/94/6a2e0f58bb184e6743178549b82db7ba4b22febf7f825a2eae58160a1ae7/maxminddb-2.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a854541202ac7e5474480ed642ccc98dfb28f3e26d7fa98034b75c668aa93362", size = 88409, upload-time = "2025-05-05T19:30:56.731Z" },
- { url = "https://files.pythonhosted.org/packages/5d/13/5b1c523e29135a7ac0d8dad69bf4f5687904be3f11a71b06c013cecdd6f6/maxminddb-2.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce35f28f60921d853a4ff655c3eb1be94bd3bbaeb142b3e02fa265e725710e1c", size = 93166, upload-time = "2025-05-05T19:30:57.963Z" },
- { url = "https://files.pythonhosted.org/packages/b1/a6/1ee5649ca89252d30febc2c15e64bedf1001624548224aa425e8eaa031f7/maxminddb-2.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0aa3fdc5688052cd5bd9000bc5d11c783ff6a887d76ec8206c71f92df2da64f2", size = 89463, upload-time = "2025-05-05T19:30:59.291Z" },
- { url = "https://files.pythonhosted.org/packages/b8/29/00670dab2eb0cab1a0b3e4e2c277664f1f98bb94347dee9afcf4b7d54a78/maxminddb-2.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d40ae2a1869d4e06b32da34bf676eeb7da810870eb14b9dfd9a294c4726fa02", size = 87735, upload-time = "2025-05-05T19:31:00.494Z" },
- { url = "https://files.pythonhosted.org/packages/cb/b7/f062ce4b6a2cc6ecd1ddb33a249409f104182e0bf2988bdefe6ee24672b4/maxminddb-2.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38ae82838beb1c3893bf6917beec985fc3e2843cba84fb4a8bebaa36802ea936", size = 86316, upload-time = "2025-05-05T19:31:01.709Z" },
- { url = "https://files.pythonhosted.org/packages/56/de/96c6c9c64802d073df8c110fe8c02732013a8da350dc782b684d10c53f3a/maxminddb-2.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b834d21e62f3e0275490a20e9e80f270ee7a93af43cd54082be712a98d2ee307", size = 91924, upload-time = "2025-05-05T19:31:02.894Z" },
- { url = "https://files.pythonhosted.org/packages/55/e9/bcd80ed8b05e328446d3c585ca7ce1e48e320e7f95785388d464b66e8358/maxminddb-2.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5a894820a7f11b86288a3e265ac613944a78b1556f24a1ca9b8ad012a6343b74", size = 90197, upload-time = "2025-05-05T19:31:04.127Z" },
- { url = "https://files.pythonhosted.org/packages/6a/c2/fd4cd67bd37d9efff9f8af827e8fc160feea9e6d1814fc599c5b7a8727fe/maxminddb-2.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:e1c3f0f23917e2bd348342df63c355311cd112efd4924235caf26b67fcb3e256", size = 97205, upload-time = "2025-05-05T19:31:05.337Z" },
- { url = "https://files.pythonhosted.org/packages/aa/5c/2057c8d9162b830be28cb773b698ba86af1e03c07a5ef156f4ecd3282c0b/maxminddb-2.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4202dba1f7284cfbcfd0d8324a3234bf5527f320cad347572f76dd77992eed51", size = 94498, upload-time = "2025-05-05T19:31:06.549Z" },
- { url = "https://files.pythonhosted.org/packages/82/e2/8e82227f4129cee2e9b0c53151adfd8b492a2622021329a36d9570166c89/maxminddb-2.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bc2fb57a103b70acdd1272af5f53ee1f23b92b664811cf365ce7c0af824a9c69", size = 90113, upload-time = "2025-05-05T19:31:07.73Z" },
- { url = "https://files.pythonhosted.org/packages/92/69/ae591edd8cc5f6748e6ea7f5483a3d4769aafddabcef41a243fb7636efab/maxminddb-2.7.0-cp39-cp39-win32.whl", hash = "sha256:5bfadd12a5212ec51edeb0a6a87e85060c36cb754b59d246c081de9de169dcaf", size = 34551, upload-time = "2025-05-05T19:31:09.016Z" },
- { url = "https://files.pythonhosted.org/packages/4b/9d/c08a77ffffeeada0a5609a99c463d8c75ba5365fc2958cc97a383751f2d4/maxminddb-2.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:269e3cb21b27bdee1ca037cdbe24dcf84d4e0aa47482bc24a509c6b7a409b66b", size = 36598, upload-time = "2025-05-05T19:31:10.165Z" },
- { url = "https://files.pythonhosted.org/packages/7c/94/3d21ae451769607e2e094e4ffed6cd984e5071b9cc5222232a93dcb88325/maxminddb-2.7.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d29236bc5349c54ab1ea121b707069c67cb4be2c1c25d5456bb3324e2a220987", size = 34139, upload-time = "2025-05-05T19:31:11.314Z" },
- { url = "https://files.pythonhosted.org/packages/36/0d/160e9255834b41ed0d3c855357122aff9dc80d3b320890a1f687654fe056/maxminddb-2.7.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6930d6ba283416fc50c145f9845ffd8d9d373325d2c8b7b691098ebd14c8679c", size = 33737, upload-time = "2025-05-05T19:31:12.614Z" },
- { url = "https://files.pythonhosted.org/packages/94/c2/65f59449ac72c061c69b7440c5344561dfde8931e088a8e6d06b31e312f0/maxminddb-2.7.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca12a265926bd6784f8f44a881e347fad75c815d6cff43eab969719d3da2a34f", size = 37243, upload-time = "2025-05-05T19:31:13.968Z" },
- { url = "https://files.pythonhosted.org/packages/48/43/5718f5cbfe26e5078e4362873ccec1d09c1def04920974e2bd2339aa4c31/maxminddb-2.7.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a6156146ba2cd47d6864667f8d92e1370677593ec4d7843d5c3244aeac81b34", size = 36892, upload-time = "2025-05-05T19:31:15.672Z" },
- { url = "https://files.pythonhosted.org/packages/8b/68/088b397525763c869b89d9c7fae975a348d74d23657c09c8dff937cf39ce/maxminddb-2.7.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07aff6856646de82d4491788203af47827ced407ff9a37d38a736fe559ec88d8", size = 38462, upload-time = "2025-05-05T19:31:17.174Z" },
- { url = "https://files.pythonhosted.org/packages/26/31/5a78b679a4edbd55b95c578be108cc85e43bc6ca3cb1121a7faccda16b29/maxminddb-2.7.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:facccbb308af820d3d56f0c8edb84a4e8f525b7adda5e9bbf140cc30392a246c", size = 36647, upload-time = "2025-05-05T19:31:21.863Z" },
- { url = "https://files.pythonhosted.org/packages/91/8c/5c19f2f34110d9b103d3c4f45fd9e4658d93cbc8520ede60dc7275e1b111/maxminddb-2.7.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:eac98e1440839f75f7f8696d574825b9766066496151f5c8575927649db725fd", size = 34142, upload-time = "2025-05-05T19:31:23.563Z" },
- { url = "https://files.pythonhosted.org/packages/a4/2c/fff09bb635ac5657c0ed6c538a54c7de50c702d4ae4b41d1ed107e25b61d/maxminddb-2.7.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89f03d2260ed7a73e13e318fa52d4079920e451d2c35cfc99c9a5d520be673b9", size = 33736, upload-time = "2025-05-05T19:31:25.213Z" },
- { url = "https://files.pythonhosted.org/packages/b3/9f/7fd5474cb657f53c74c897df6aef835c0d4a95e9ee696b4ad0597081e504/maxminddb-2.7.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b77885182efd9cd09644b2bd42db289edbfd646fc3ab4915bce8420a6120c154", size = 37244, upload-time = "2025-05-05T19:31:26.364Z" },
- { url = "https://files.pythonhosted.org/packages/83/09/0bd54ac72238215cd54b6d591b7842d99ac8a4bcf377d99ecf3e77b10732/maxminddb-2.7.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73936e26ca85d46ec45d8354554a24a70a62b4c6c6d6bcb6480f2aed36ce29b9", size = 36892, upload-time = "2025-05-05T19:31:30.083Z" },
- { url = "https://files.pythonhosted.org/packages/7b/e5/b5dcd6ae20a1af0fa1b59faae8a6f26327e413e22d059bbf1777532eadd9/maxminddb-2.7.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce3974ad93aedd41b46fe46aa905c7fd0167bb6abff974db71f4e68cbd0f437d", size = 38462, upload-time = "2025-05-05T19:31:31.73Z" },
- { url = "https://files.pythonhosted.org/packages/8c/4f/9cba7ad53729357a125b65f082c12435705dcd551859785714acc2f5fa18/maxminddb-2.7.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:49501def556b55c67a0fcbcbde7f0d5bd13755874b3bf5599dc47fd2dd505365", size = 36646, upload-time = "2025-05-05T19:31:33.453Z" },
- { url = "https://files.pythonhosted.org/packages/c2/7c/7c0e78d2388c66176d1f9f47cdd8b75be888c69a00847145313d4e969c28/maxminddb-2.7.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ade95be6fd3bf07fd65e693d865b0751b7c8c1fc6b4b9c6191bb4d3d92d4f5ac", size = 34137, upload-time = "2025-05-05T19:31:35.968Z" },
- { url = "https://files.pythonhosted.org/packages/99/4f/7b5406779dbb033209144dd95950e2b4f3981cf742b9359ab75c5ae1c3f4/maxminddb-2.7.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4bd31f0971156bcc9b4f0fab790ce8a4bc84357776a81195ae4f9cba659fda8b", size = 33739, upload-time = "2025-05-05T19:31:37.214Z" },
- { url = "https://files.pythonhosted.org/packages/54/ad/fb3dfedd876ec235a2b417eabd92c0d029fd2c89d726cecebdd775d4bb50/maxminddb-2.7.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f9edb0c539b0f28d9df30ee42e92cf11fbc729d428dc1e24b8e9861b2133e2", size = 37243, upload-time = "2025-05-05T19:31:38.419Z" },
- { url = "https://files.pythonhosted.org/packages/1e/2d/4046e2ce349bc3f5329fc52cc0dde1c4363198a87c6d3972c3cae90eebca/maxminddb-2.7.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ffbe8c321b234eb2886c5c6cb16cb0a7a635a7f8f9a944a1eaac1bcfbe3fc7d", size = 36889, upload-time = "2025-05-05T19:31:39.753Z" },
- { url = "https://files.pythonhosted.org/packages/b1/a2/3f96e184b385801078631b50adb215aa6a424b1503e0517e4b37339c205d/maxminddb-2.7.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f855bc9c0f409dc4f155cc1f639f6bd550463ccd29ff2b437253f65361f99321", size = 38466, upload-time = "2025-05-05T19:31:40.982Z" },
- { url = "https://files.pythonhosted.org/packages/75/ab/6677aed7f7b1132ff1fbc2a499d75d1c7243b34839973df61384ac550991/maxminddb-2.7.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:15b178a5e1af71d7ea8e5374b3ae92d4ccbe52998dc57a737793d6dd029ec97c", size = 36647, upload-time = "2025-05-05T19:31:42.275Z" },
+version = "2.8.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/9c/5af549744e7a1e986bddd119c0bbca7f7fa7fb72590b554cb860a0c3acb1/maxminddb-2.8.2.tar.gz", hash = "sha256:26a8e536228d8cc28c5b8f574a571a2704befce3b368ceca593a76d56b6590f9", size = 194388, upload-time = "2025-07-25T20:32:05.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/73/f5/0f66cee71b252934bbdffc7b93de56f83a9f0a85b46d73d3595d39108206/maxminddb-2.8.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3db07d41644fbb712f31d8837feb3109a8b73f42f7ef1be32b3eb84af96f062b", size = 52245, upload-time = "2025-07-25T20:29:55.016Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/78/738d0b5d6fd6070175a1a0c7158ffc2615764d21c3b6402ce0ff731fc1c3/maxminddb-2.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb7797d3cf35160f5ed54e12e7bddb12ec011e838bedc9201f7c2987ea284a3c", size = 35194, upload-time = "2025-07-25T20:29:56.445Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/bc/a07567c1ae7b60c79fcdeb704e7cf0d87292dd557062a7ee4fdc401bf6b7/maxminddb-2.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:08df1edfb85bd2e30e8f7a2c512be15c5c169492e5972afd3ddab7c498b5aad2", size = 35004, upload-time = "2025-07-25T20:29:58.002Z" },
+ { url = "https://files.pythonhosted.org/packages/88/3c/2d009b59b89fad5a3017f2185ef55f59a31fe2a591c2a3ec8d3c27943bdc/maxminddb-2.8.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c671d56b95543a28ec05628fa139d9db9f43f53f09f466b6b2d0dae09adddb", size = 94719, upload-time = "2025-07-25T20:29:59.487Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/32/c075774a6873451cbf0afcbb4c4fdba7e9a8c406ec5dc100c1550fbc7529/maxminddb-2.8.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3f7453048c0f20750a77091eb38443abf1e30f6d6e41de3b8358ea6e7cd73730", size = 92316, upload-time = "2025-07-25T20:30:01.579Z" },
+ { url = "https://files.pythonhosted.org/packages/97/5a/791016f1d4474b17698f6d2145d0336d2f017bf705c480ce12f2c6208833/maxminddb-2.8.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:990b7993503e77e44baed17f2c7cd1006112f54bd132af354ef4640c6d83a68b", size = 92259, upload-time = "2025-07-25T20:30:03.253Z" },
+ { url = "https://files.pythonhosted.org/packages/02/6c/1936c7f43a84676c8f2b02d27cd6199645c35c26e21f36beb92e5d0df086/maxminddb-2.8.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:027a8bc9e622532196cb84f14f8b18d555b0937a3e0a6e95805db215f98c451b", size = 90418, upload-time = "2025-07-25T20:30:04.364Z" },
+ { url = "https://files.pythonhosted.org/packages/95/39/b6192b11d0605c09e9dcb5626bf0a4996f644893adb2b0272433852d7601/maxminddb-2.8.2-cp310-cp310-win32.whl", hash = "sha256:883e17e942631a3b99747a4dc8d55c3e20ac2e342696e828a961d9dcd1811cbb", size = 34598, upload-time = "2025-07-25T20:30:05.531Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/3e/e3316093c73da362c3ae921d8b05a1ff2da46917a488c4ed3adb88c3452d/maxminddb-2.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:472d6c61c5c1994989fbdefc7a17adec245330f3e9a11021b9460c5b9f27bcd1", size = 36680, upload-time = "2025-07-25T20:30:07.09Z" },
+ { url = "https://files.pythonhosted.org/packages/08/5e/b66837faf2bcc398af6d5b7d51cc7ea30ae46c2870ee13ab580e9328c6b8/maxminddb-2.8.2-cp310-cp310-win_arm64.whl", hash = "sha256:67828addad0cb0ef21fd37549db58a16f219cc1e9c6243b089a726dfe8dfcd34", size = 33035, upload-time = "2025-07-25T20:30:08.584Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/2a/e61a2544d69ef0d0f31dec9afe943d4e28d2667f9293f490b843620b426b/maxminddb-2.8.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7c6d18662c285bb5dfa3b8f2b222c5f77d2521f1d9260a025d8c8b8ec87916f4", size = 52246, upload-time = "2025-07-25T20:30:09.735Z" },
+ { url = "https://files.pythonhosted.org/packages/de/c7/429492073b45d50d2a636b890abe54661f3e84c844711f9d57246b7e9739/maxminddb-2.8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4fd06457cee79e465e72cf21a46c78d5a8574dfeed98b54c106f14f47d237009", size = 35194, upload-time = "2025-07-25T20:30:10.995Z" },
+ { url = "https://files.pythonhosted.org/packages/27/b1/a27b00e554ce461c7a4031c6f236a2110e0dc2540c10c2e166d67a82bd45/maxminddb-2.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:711beeb8fda0169c379e77758499f4b7feb56a89327e894fff57bf35d9fe35d5", size = 35006, upload-time = "2025-07-25T20:30:12.085Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/4d/255c7eebcaee9784665b7d73075b3aa60dc72e420db63264f0789e29e774/maxminddb-2.8.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc0eaef5f5a371484542503d70b979e14dd2efded78a19029e78c4e016d7d694", size = 94909, upload-time = "2025-07-25T20:30:13.26Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/df/debe55bf6edc34ed0572ea716d9c58c5e42d76df028cda63c86f54445fff/maxminddb-2.8.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a38f213e887c273ba14f563980f15b620bf600576d3ba530dd12416004dcd33", size = 92498, upload-time = "2025-07-25T20:30:14.747Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/cc/b0ee8e3807e5adeb7cb9cea6d59f5e3fe63001ca70b9a96ab5bdc7964160/maxminddb-2.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a3fbf0d36cb3fad3743cd2c522855577209c533a782c7176b4d54550928f6935", size = 92466, upload-time = "2025-07-25T20:30:16.478Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ca/7bfabf900ff7cadd5b8d5a259619bcb43d8fce4ef482c4d1a79c0e6f9998/maxminddb-2.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b516e113564228ed1965a2454bba901a85984aef599b61e98ce743ce94c22a07", size = 90598, upload-time = "2025-07-25T20:30:17.81Z" },
+ { url = "https://files.pythonhosted.org/packages/48/d1/70dfb4cec8190e426f7576384d3adc64ef3bff5b3fd51805c2d49334434c/maxminddb-2.8.2-cp311-cp311-win32.whl", hash = "sha256:c7fc5b3ea6b9a664712544738f14da256981031d0a951e590508a79f4d4a37d1", size = 34595, upload-time = "2025-07-25T20:30:19.362Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/0c/3633d901e0bd90933cde5b2b7200ea22f52becb882a474babd9a10031432/maxminddb-2.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:590399b8c6b41aaf42385da412bb0c0690c3db2720fb3a6e7d6967aecc4342ad", size = 36671, upload-time = "2025-07-25T20:30:20.734Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/f3/810af19728d1f834d42e7b585301f4842f386c0baa5c61d9c99ee18772da/maxminddb-2.8.2-cp311-cp311-win_arm64.whl", hash = "sha256:f63d07b6a6d402548f153e0cc31fd21ddd7825a457d4da6205fef6b9211361d8", size = 33037, upload-time = "2025-07-25T20:30:21.813Z" },
+ { url = "https://files.pythonhosted.org/packages/58/45/ff56248fbaaca9383d18d73aee60a544f0282d71e54af0bf0dea4128fda5/maxminddb-2.8.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bcfb9bc5e31875dd6c1e2de9d748ce403ca5d5d4bc6167973bb0b1bd294bf8d7", size = 52615, upload-time = "2025-07-25T20:30:23.369Z" },
+ { url = "https://files.pythonhosted.org/packages/79/44/2703121c2dbba7d03c37294dd407cca2e31dc4542543b93808dd26fd144b/maxminddb-2.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e12bec7f672af46e2177e7c1cd5d330eb969f0dc42f672e250b3d5d72e61778d", size = 35394, upload-time = "2025-07-25T20:30:24.55Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/25/99e999e630b1a44936c5261827cc94def5eec82ae57a667a76d641b93925/maxminddb-2.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b23103a754ff1e795d6e107ae23bf9b3360bce9e9bff08c58e388dc2f3fd85ad", size = 35177, upload-time = "2025-07-25T20:30:26.105Z" },
+ { url = "https://files.pythonhosted.org/packages/41/21/05c8f50c1b4138516f2bde2810d32c97b84c6d0aefe7e1a1b41635241041/maxminddb-2.8.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c4a10cb799ed3449d063883df962b76b55fdfe0756dfa82eed9765d95e8fd6e", size = 96062, upload-time = "2025-07-25T20:30:27.33Z" },
+ { url = "https://files.pythonhosted.org/packages/66/7a/ba7995d1f6b405c057e6f4bd5751fe667535b0ba84f65ee6eb1493bccb80/maxminddb-2.8.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6315977c0512cb7d982bc2eb869355a168f12ef6d2bd5a4f2c93148bc3c03fdc", size = 94208, upload-time = "2025-07-25T20:30:28.932Z" },
+ { url = "https://files.pythonhosted.org/packages/99/6f/11cc4b0f1d7f98965ef3304bd9bf2c587f5e84b99aeac27891f5661565cb/maxminddb-2.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b24594f04d03855687b8166ee2c7b788f1e1836b4c5fef2e55fc19327f507ac", size = 93448, upload-time = "2025-07-25T20:30:30.438Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/d5/31664be079b71b30895875d6781ae08f871d67de04e518c64422271a8b25/maxminddb-2.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b07b72d9297179c74344aaecad48c88dfdea4422e16721b5955015800d865da2", size = 92240, upload-time = "2025-07-25T20:30:31.658Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/19/a5931bb077ccb7e719b8a602fb3ffcd577cdd4954cae3d2b9201272cd462/maxminddb-2.8.2-cp312-cp312-win32.whl", hash = "sha256:51d9717354ee7aa02d52c15115fec2d29bb33f31d6c9f5a8a5aaa2c25dc66e63", size = 34751, upload-time = "2025-07-25T20:30:32.883Z" },
+ { url = "https://files.pythonhosted.org/packages/63/50/25720ed19f2d62440b94a1333656cccf6c3c1ce2527ed9abf7b35e2557e1/maxminddb-2.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:18132ccd77ad68863b9022451655cbe1e8fc3c973bafcad66a252eff2732a5c1", size = 36782, upload-time = "2025-07-25T20:30:34.378Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/30/1c3121365114678d8df4c02fd416d7520c86b1e37708cc7134ccc3c06e78/maxminddb-2.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:59934eb00274f8b7860927f470a2b9b049842f91e2524a24ade99e16755320f2", size = 33040, upload-time = "2025-07-25T20:30:35.474Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/33/06d8d8eb2e422bbff372628c23ce09a2d51f50b9283449c5d8cef0225fe3/maxminddb-2.8.2-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b32a8b61e0dae09c80f41dcd6dc4a442a3cc94b7874a18931daecfea274f640c", size = 36642, upload-time = "2025-07-25T20:30:36.627Z" },
+ { url = "https://files.pythonhosted.org/packages/41/c1/dca3608b85d3889760bdf98e931ac66e236f9b8da640f47461c8549fe931/maxminddb-2.8.2-cp313-cp313-android_21_x86_64.whl", hash = "sha256:5f12674cee687cd41c9be1c9ab806bd6a777864e762d5f34ec57c0afa9a21411", size = 37052, upload-time = "2025-07-25T20:30:37.912Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/e0/3af26974a2c267939c394d6481723021bdb67af570f948cf510f80e6aeb1/maxminddb-2.8.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:995a506a02f70a33ba5ee9f73ce737ef8cdb219bfca3177db79622ebc5624057", size = 34381, upload-time = "2025-07-25T20:30:39.363Z" },
+ { url = "https://files.pythonhosted.org/packages/28/ce/26e06d888f057f98b4bc269ee0f8d0ede3dad9684d38e4033acc444b08e5/maxminddb-2.8.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5ef9b7f106a1e9ee08f47cd98f7ae80fa40fc0fd40d97cf0d011266738847b52", size = 34918, upload-time = "2025-07-25T20:30:40.512Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/a2/0e23f5c33461d1d43d201f2c741c6318d658907833d22cec4ee475d6fab8/maxminddb-2.8.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:adeceeb591755b36a0dc544b92f6d80fc5c112519f5ed8211c34d2ad796bfac0", size = 52619, upload-time = "2025-07-25T20:30:41.645Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/ec/3a69a57a9ba4c7d62105fe235642f744bf4ef7cd057f8019a14b1b8eea6d/maxminddb-2.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5c8df08cbdafaa04f7d36a0506e342e4cd679587b56b0fad065b4777e94c8065", size = 35399, upload-time = "2025-07-25T20:30:42.804Z" },
+ { url = "https://files.pythonhosted.org/packages/30/b3/b904e778e347ed40e5c82717609e1ecdcdff6c7d7ea2f844a6a20578daef/maxminddb-2.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3e982112e239925c2d8739f834c71539947e54747e56e66c6d960ac356432f32", size = 35165, upload-time = "2025-07-25T20:30:45.534Z" },
+ { url = "https://files.pythonhosted.org/packages/34/da/685eeae2ad155d970efabad5ca86ed745665a2ff7576d8fa3d9b9bdb7f8a/maxminddb-2.8.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ef30c32af0107e6b0b9d53f9ae949cf74ddb6882025054bd7500a7b1eb02ec0", size = 96127, upload-time = "2025-07-25T20:30:46.716Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/24/a7f54b2b6d808cc4dd485adc004fcd66e103d0aacbf448afd419c0c18380/maxminddb-2.8.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685df893f44606dcb1353b31762b18a2a9537015f1b9e7c0bb3ae74c9fbced32", size = 94250, upload-time = "2025-07-25T20:30:48.45Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/cb/bbc5c11201497d7dd42d3240141a8ec484ff704afdf6dff7a7a2de5a6291/maxminddb-2.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3dc27c443cf27b35d4d77ff90fbc6caf1c4e28cffd967775b11cf993af5b9d1", size = 93399, upload-time = "2025-07-25T20:30:50.052Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/e6/521c750ea7480fbe362b7bb2821937544313fd3b697f30f4c1975b85c816/maxminddb-2.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:742e857b4411ae3d59c555c2aa96856f72437374cf668c3bed18647092584af6", size = 92250, upload-time = "2025-07-25T20:30:51.259Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4b/9a522ba96a48882c7a954636411f05994573af2eed4b93b511ca6ea3d023/maxminddb-2.8.2-cp313-cp313-win32.whl", hash = "sha256:1fba9c16f5e492eee16362e8204aaec30241167a3466874ca9b0521dec32d63e", size = 34759, upload-time = "2025-07-25T20:30:52.936Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/4a/e0d7451b56821fe0ec794a917cceb67efac8510013783cc5713b733d5ff4/maxminddb-2.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:cfbfee615d2566124cb6232401d89f15609f5297eb4f022f1f6a14205c091df6", size = 36771, upload-time = "2025-07-25T20:30:54.076Z" },
+ { url = "https://files.pythonhosted.org/packages/71/27/abffb686514905994ef26191971ca30765c45e391d82ee2ea6b2ecfe1bad/maxminddb-2.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:2ade954d94087039fc45de99eeae0e2f0480d69a767abd417bd0742bf5d177ab", size = 33041, upload-time = "2025-07-25T20:30:55.567Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d2/844530632ef917f622742d6d5beae5c3ebed7d424af02bf428b639e42a41/maxminddb-2.8.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7d5db6d4f8caaf7b753a0f6782765ea5352409ef6d430196b0dc7c61c0a8c72b", size = 34384, upload-time = "2025-07-25T20:30:57.046Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/6c/ff9555963983d99a201a5068ab037c92583cd8422046d7064e2cab92c09f/maxminddb-2.8.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bda6015f617b4ec6f1a49ae74b1a36c10d997602d3e9141514ef11983e6ddf8d", size = 34929, upload-time = "2025-07-25T20:30:58.194Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/c2/8d093e973edb1ca0ad54a80f124b4e8d1db5508a00c0f98765d0df6bd4d5/maxminddb-2.8.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:4e32f5608af05bc0b6cee91edd0698f6a310ae9dd0f3cebfb524a6b444c003a2", size = 52616, upload-time = "2025-07-25T20:30:59.294Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/85/8442162353c28ff0679f348d2099f24d9be9b84f9ffa1ed21e8ecafe64dc/maxminddb-2.8.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5abf18c51f3a3e5590ea77d43bff159a9f88cec1f95a7e3fc2a39a21fc8f9e7c", size = 35405, upload-time = "2025-07-25T20:31:00.821Z" },
+ { url = "https://files.pythonhosted.org/packages/14/df/f37d5b2605ae0f1d3f87d45ddbab032f36b2cae29f80f02c390001b35677/maxminddb-2.8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3c8d57063ff2c6d0690e5d907a10b5b6ba64e0ab5e6d8661b6075fbda854e97d", size = 35174, upload-time = "2025-07-25T20:31:02.112Z" },
+ { url = "https://files.pythonhosted.org/packages/32/12/5d562de6243b8631f9480b7deac92cb62ec5ae8aecd4e3ccdaecfc177c24/maxminddb-2.8.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73d603c7202e1338bdbb3ead8a3db4f74825e419ecc8733ef8a76c14366800d2", size = 96060, upload-time = "2025-07-25T20:31:03.318Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/95/04c8c2526e4c0c0d2894052c7d07f39c9b8d1185bd2da5752de2effc287a/maxminddb-2.8.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:acca37ed0372efa01251da32db1a5d81189369449bc4b943d3087ebc9e30e814", size = 94013, upload-time = "2025-07-25T20:31:04.592Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/98/7870de3e5cf362c567c0a9cf7a8834d3699fe0a52e601fc352c902d3ebc7/maxminddb-2.8.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1e1e3ef04a686cf7d893a8274ddc0081bd40121ac4923b67e8caa902094ac111", size = 93350, upload-time = "2025-07-25T20:31:05.815Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/ef/7eb25529011cf0e18fb529792ad5225b402a3e80728cfbd7604e53c5ada3/maxminddb-2.8.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6657615038d8fe106acccd2bf4fe073d07f72886ee893725c74649687635a1a", size = 92036, upload-time = "2025-07-25T20:31:07.03Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/9d/12926eac198a920a2c4f9ce6e57de33d47a6c40ccb1637362abfd268f017/maxminddb-2.8.2-cp314-cp314-win32.whl", hash = "sha256:af058500ab3448b709c43f1aefd3d9f7c5f1773af07611d589502ea78bf2b9dc", size = 35403, upload-time = "2025-07-25T20:31:08.221Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/eb/48636b611f604bb072b26be16e6990694bbfdd57553622a784b17c1999c7/maxminddb-2.8.2-cp314-cp314-win_amd64.whl", hash = "sha256:b5982d1b53b50b96a9afcf4f7f49db0a842501f9cf58c4c16c0d62c1b0d22840", size = 37559, upload-time = "2025-07-25T20:31:09.448Z" },
+ { url = "https://files.pythonhosted.org/packages/05/4a/27e53d1b9b7b168f259bbfccec1d1383d51c07e112d7bd24e543042e07a1/maxminddb-2.8.2-cp314-cp314-win_arm64.whl", hash = "sha256:48c9f7e182c6e970a412c02e7438c2a66197c0664d0c7da81b951bff86519dd5", size = 33614, upload-time = "2025-07-25T20:31:10.555Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/43/e49927eb381fb44c9a06a5ac06da039951fde90bf47f100b495f082d6b37/maxminddb-2.8.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b40ed2ec586a5a479d08bd39838fbfbdff84d7deb57089317f312609f1357384", size = 53708, upload-time = "2025-07-25T20:31:11.642Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/d0/ff081ac508358b3a9ca1f0b39d5bf74904aa644b45d2d6d8b9112ad9566e/maxminddb-2.8.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1ba4036f823a8e6418af0d69734fb176e3d1edd0432e218f3be8362564b53ea5", size = 35925, upload-time = "2025-07-25T20:31:12.804Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/30/f94d3acca0314f038a4f1cb83ccbdf0a56b9f13454bab9667af0506ecca0/maxminddb-2.8.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:96531e18bddff9639061ee543417f941a2fd41efc7b1699e1e18aba4157b0b03", size = 35757, upload-time = "2025-07-25T20:31:14.322Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/21/5710a5aa7f83453fcf36cee11ed113c110a53cdc5a4ecf82904be797101b/maxminddb-2.8.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb77ad5c585d6255001d701eafc4758e2d28953ba47510d9f54cc2a9e469c6b6", size = 104991, upload-time = "2025-07-25T20:31:15.542Z" },
+ { url = "https://files.pythonhosted.org/packages/47/0c/8cf559f850c3e43e6f490fad458293fdb0b70debbe3fcbf7d7713558044f/maxminddb-2.8.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bfd950af416ef4133bc04b059f29ac4d4b356927fa4a500048220d65ec4c6ac", size = 101935, upload-time = "2025-07-25T20:31:16.83Z" },
+ { url = "https://files.pythonhosted.org/packages/02/47/104ef451772d1cd852dea2334c2dfb02d6de7caf8d31e1358f10b9af6769/maxminddb-2.8.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bf73612f8fbfa9181ba62fa88fb3d732bdc775017bdb3725e24cdd1a0da92d4", size = 101653, upload-time = "2025-07-25T20:31:18.104Z" },
+ { url = "https://files.pythonhosted.org/packages/60/03/139791f82e3857d4d0638494647f74d997a2abded7048ab4ed4622a089ad/maxminddb-2.8.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:74361fbddb0566970af38cff0a6256ec3f445cb5031da486d0cee6f19ccb9e2e", size = 99517, upload-time = "2025-07-25T20:31:19.764Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/45/c625fc2b84b8dcf2181eb411f130729446164215409c8e0c8fd01a53f388/maxminddb-2.8.2-cp314-cp314t-win32.whl", hash = "sha256:6bfb41c3a560a60fc20d0d87cb400003974fbb833b44571250476c2d9cb4d407", size = 36349, upload-time = "2025-07-25T20:31:21.004Z" },
+ { url = "https://files.pythonhosted.org/packages/27/8d/46c202be273fd8ec985686e1fdd84ad55c7234dc66d82d6d59e5caf438e4/maxminddb-2.8.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ec6bba1b1f0fd0846aac5b0af1f84804c67702e873aa9d79c9965794a635ada8", size = 38595, upload-time = "2025-07-25T20:31:22.185Z" },
+ { url = "https://files.pythonhosted.org/packages/62/33/09601f476fd9d494e967f15c1e05aa1e35bdf5ee54555596e05e5c9ec8c9/maxminddb-2.8.2-cp314-cp314t-win_arm64.whl", hash = "sha256:929a00528db82ffa5aa928a9cd1a972e8f93c36243609c25574dfd920c21533b", size = 33990, upload-time = "2025-07-25T20:31:23.367Z" },
+ { url = "https://files.pythonhosted.org/packages/39/e3/238393797fd82c34c54990c4d4546ae34315735c9219fe7e0c8d2a3d74ee/maxminddb-2.8.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9b27485e54eee7c251846cfc3b3277b1fdbdae6b6bbc26015c360de7ce78ae33", size = 52244, upload-time = "2025-07-25T20:31:24.521Z" },
+ { url = "https://files.pythonhosted.org/packages/85/87/c9c1d53a8b23cc00ce310c803bd54dfda3f10544f04f3faf2c4d1f0321c3/maxminddb-2.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c335db4abdd79e3846deb2aa72374284eae78bb2622a82a29c5fd7dd42741a11", size = 35199, upload-time = "2025-07-25T20:31:25.782Z" },
+ { url = "https://files.pythonhosted.org/packages/53/b9/0b119b8ca2b0116d7f09efb24d8cf680ef20943d7995d804acf179b89b38/maxminddb-2.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c6ff6b84327bb4521068ab6e62f6b537641d106b1acabbdc6436ab7a74ce1328", size = 35002, upload-time = "2025-07-25T20:31:27.009Z" },
+ { url = "https://files.pythonhosted.org/packages/27/3d/6a97e72bebc2d2947554b69a68203fa352c0868aa7f2fff0b98736217bc2/maxminddb-2.8.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dccb69b63aac9b9b7c5f251e9abc0c945c9bd1681869ca72b7e6f512009b541", size = 94459, upload-time = "2025-07-25T20:31:28.613Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/1b/3576d131f6d77288036a314551511b66d0ae0d56a1cba0fc86b145d7a419/maxminddb-2.8.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9efa8a04f546f3c91a235256d61f2985f0a45bb1ec3559bbb551906c015d9464", size = 92076, upload-time = "2025-07-25T20:31:29.919Z" },
+ { url = "https://files.pythonhosted.org/packages/84/84/636a728c0df7de1a1df21ae55512b421e9c156c27c48bfe3f96e727038ba/maxminddb-2.8.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5853b9f1fb4fc2b394b6ddce33a0be6711b80c8df86498a6e9e90057f0e7276f", size = 91981, upload-time = "2025-07-25T20:31:31.587Z" },
+ { url = "https://files.pythonhosted.org/packages/14/da/c98f2e60398f1c0070fa5ac134230014cd6b9a05080316474add4d2ad88a/maxminddb-2.8.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0d39044f19696a3bca319539c8cd159c3c5af99d1ee381da6e4b273b6a27c728", size = 90179, upload-time = "2025-07-25T20:31:33.914Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/16/36012be72ac75910c93dd07c85c983e51d1f558da8064c38888e49b7f74c/maxminddb-2.8.2-cp39-cp39-win32.whl", hash = "sha256:56a84983debc7b8d9874c9c739106b860f9d4f120b0179085ffb500704c31266", size = 34603, upload-time = "2025-07-25T20:31:35.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/79/62d637834c86c15d98a813c76df5c6839c3445d19f90f6ffa8cf489dbf5c/maxminddb-2.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:2f754550d51c25233853cdcbae1ee384a2af9e3e422b54b992bd4cef6332f894", size = 36682, upload-time = "2025-07-25T20:31:36.385Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/96/4780cd9f6caa3c60f8d0d11fc102ef5f3283af656eec2cd581244ae96b8c/maxminddb-2.8.2-cp39-cp39-win_arm64.whl", hash = "sha256:1c319d257fa3e8225ec2eece0043687ad64bf3968de9432187376eb97c2ac6da", size = 33025, upload-time = "2025-07-25T20:31:37.565Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/46/741e1945fc64f7cf5a5d399a15c673d5d30899480db17ddaea270c41f120/maxminddb-2.8.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ed8d6742e66b119e66a658307bba5da32ba3f7e4e99a35a770dcf924e51326a5", size = 34209, upload-time = "2025-07-25T20:31:38.681Z" },
+ { url = "https://files.pythonhosted.org/packages/24/13/78361b264ccc275c7e64a3ba29951560d0231990bf64d03cd9cc6a561e67/maxminddb-2.8.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:464b6e4269b9feea12c63eb1561038fac5f1b449a14b78be250ad081b560ff3c", size = 33806, upload-time = "2025-07-25T20:31:39.804Z" },
+ { url = "https://files.pythonhosted.org/packages/84/dc/9e4578ba5a44057d8cc843aa139bf70f2a4d6b3a2d2be5eb6b5848836346/maxminddb-2.8.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:833247b194d86bc62e16d36169336daebba777414821fd0003b1ecfc6bb3f1a7", size = 38143, upload-time = "2025-07-25T20:31:41.034Z" },
+ { url = "https://files.pythonhosted.org/packages/73/19/f7922739c61aed246f5d6e032e7d3df4239c33ffb090a8eee5a644c80d35/maxminddb-2.8.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d8d30c6038bdc7ad0458598e4b8c54f19cb052853ac84a0be8902c7af3a009f", size = 36974, upload-time = "2025-07-25T20:31:42.21Z" },
+ { url = "https://files.pythonhosted.org/packages/be/54/28bddcd972a665244f6714a1979b7bea01fb4f689e4fa178e28b65d4fbb9/maxminddb-2.8.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f6da4d844f176b7a662446107dd09b987759126c2d8c266918fe7f0186d41538", size = 36719, upload-time = "2025-07-25T20:31:43.444Z" },
+ { url = "https://files.pythonhosted.org/packages/55/a9/50aa454bdf8aa76c7c8cf8343b039461203d4b53d5c3f4eecdb180574981/maxminddb-2.8.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:28205d215b426c31c35ecc2e71f6ee22ebf12a9a7560ed1efec3709e343d720b", size = 34139, upload-time = "2025-07-25T20:31:44.668Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/af/610036e75aa0aebc67e47f89aea73cc2fa92288eb72f4141cf061e0e5673/maxminddb-2.8.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:88b7be82d81a4de2ea40e9bd1f39074ac2d127268a328ad524500c3c210eced1", size = 33735, upload-time = "2025-07-25T20:31:46.341Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/c2/b8f8748405c344c03684b12267ec7d8e99c33d8c610da76892ce9a1827f2/maxminddb-2.8.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9a37c151ccdff7ae0be86eff1c464db02237e428f079300b3efc07277762334", size = 38140, upload-time = "2025-07-25T20:31:47.971Z" },
+ { url = "https://files.pythonhosted.org/packages/46/ec/25a20b61cf43b2fab1524817f59116132e40c5a272a0dfca1c465ed66324/maxminddb-2.8.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ff2045eadfad106824ff4fe2045e7f8ca737405e3201a9adfa646e2e6cdfad7", size = 36975, upload-time = "2025-07-25T20:31:49.181Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/10/8ed5b99189eb380bf7166fd38594f9457c5ba587a3300cc1ec64ddc4a0a6/maxminddb-2.8.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:869add1b2c9c48008e13c8db204b681a82cbe815c5f58ab8267205b522c852c0", size = 36718, upload-time = "2025-07-25T20:31:51.976Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/f5/9b51102f1e07f891330040a2b6628706eed87d7d9df7164867dd726355a3/maxminddb-2.8.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8d85e20807ee11494fce001cffdb1364729e154041739813fb261f866865522c", size = 34205, upload-time = "2025-07-25T20:31:53.772Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/a0/df86f19ba49863bb264f4f34655c8b7727979ab0b792061a93bb47603774/maxminddb-2.8.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:622fde1542a4753a39253d138438e1f543edb8455fd70a8f4afbe0a0bc04fe1e", size = 33807, upload-time = "2025-07-25T20:31:55.743Z" },
+ { url = "https://files.pythonhosted.org/packages/db/a8/6bd38cf4e40f6144c21b48952a20e9f4d90c43de740939652939b0b93ce2/maxminddb-2.8.2-pp39-pypy39_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79492896ec7f6e029c2aa92c4cc10ad0347a03b866025bd26a6f415982a833de", size = 38142, upload-time = "2025-07-25T20:31:59.374Z" },
+ { url = "https://files.pythonhosted.org/packages/01/61/a92ba49c681ac2c039a06d07847c255bbfd4956f849242107f9b0fd85307/maxminddb-2.8.2-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd42526b902755d383108bf2ba38fb9a946ec369faeead3cbe8ffc034a0462e0", size = 36976, upload-time = "2025-07-25T20:32:01.43Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/9b/2444b0dd5adba12b6ea33065afa4e4abc89e08b64339dded64d3b3964929/maxminddb-2.8.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:40e113e56ae90d3410bbfc20f5510308c29aa6815964f59859aff4187d21db8c", size = 36723, upload-time = "2025-07-25T20:32:03.127Z" },
]
[[package]]
@@ -806,7 +1295,8 @@ name = "mongomock"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "packaging" },
+ { name = "packaging", version = "24.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "packaging", version = "25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "pytz" },
{ name = "sentinels" },
]
@@ -817,116 +1307,158 @@ wheels = [
[[package]]
name = "multidict"
-version = "6.4.4"
+version = "6.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/91/2f/a3470242707058fe856fe59241eee5635d79087100b7042a867368863a27/multidict-6.4.4.tar.gz", hash = "sha256:69ee9e6ba214b5245031b76233dd95408a0fd57fdb019ddcc1ead4790932a8e8", size = 90183, upload-time = "2025-05-19T14:16:37.381Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1f/92/0926a5baafa164b5d0ade3cd7932be39310375d7e25c9d7ceca05cb26a45/multidict-6.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8adee3ac041145ffe4488ea73fa0a622b464cc25340d98be76924d0cda8545ff", size = 66052, upload-time = "2025-05-19T14:13:49.944Z" },
- { url = "https://files.pythonhosted.org/packages/b2/54/8a857ae4f8f643ec444d91f419fdd49cc7a90a2ca0e42d86482b604b63bd/multidict-6.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b61e98c3e2a861035aaccd207da585bdcacef65fe01d7a0d07478efac005e028", size = 38867, upload-time = "2025-05-19T14:13:51.92Z" },
- { url = "https://files.pythonhosted.org/packages/9e/5f/63add9069f945c19bc8b217ea6b0f8a1ad9382eab374bb44fae4354b3baf/multidict-6.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:75493f28dbadecdbb59130e74fe935288813301a8554dc32f0c631b6bdcdf8b0", size = 38138, upload-time = "2025-05-19T14:13:53.778Z" },
- { url = "https://files.pythonhosted.org/packages/97/8b/fbd9c0fc13966efdb4a47f5bcffff67a4f2a3189fbeead5766eaa4250b20/multidict-6.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffc3c6a37e048b5395ee235e4a2a0d639c2349dffa32d9367a42fc20d399772", size = 220433, upload-time = "2025-05-19T14:13:55.346Z" },
- { url = "https://files.pythonhosted.org/packages/a9/c4/5132b2d75b3ea2daedb14d10f91028f09f74f5b4d373b242c1b8eec47571/multidict-6.4.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87cb72263946b301570b0f63855569a24ee8758aaae2cd182aae7d95fbc92ca7", size = 218059, upload-time = "2025-05-19T14:13:56.993Z" },
- { url = "https://files.pythonhosted.org/packages/1a/70/f1e818c7a29b908e2d7b4fafb1d7939a41c64868e79de2982eea0a13193f/multidict-6.4.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9bbf7bd39822fd07e3609b6b4467af4c404dd2b88ee314837ad1830a7f4a8299", size = 231120, upload-time = "2025-05-19T14:13:58.333Z" },
- { url = "https://files.pythonhosted.org/packages/b4/7e/95a194d85f27d5ef9cbe48dff9ded722fc6d12fedf641ec6e1e680890be7/multidict-6.4.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1f7cbd4f1f44ddf5fd86a8675b7679176eae770f2fc88115d6dddb6cefb59bc", size = 227457, upload-time = "2025-05-19T14:13:59.663Z" },
- { url = "https://files.pythonhosted.org/packages/25/2b/590ad220968d1babb42f265debe7be5c5c616df6c5688c995a06d8a9b025/multidict-6.4.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb5ac9e5bfce0e6282e7f59ff7b7b9a74aa8e5c60d38186a4637f5aa764046ad", size = 219111, upload-time = "2025-05-19T14:14:01.019Z" },
- { url = "https://files.pythonhosted.org/packages/e0/f0/b07682b995d3fb5313f339b59d7de02db19ba0c02d1f77c27bdf8212d17c/multidict-6.4.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4efc31dfef8c4eeb95b6b17d799eedad88c4902daba39ce637e23a17ea078915", size = 213012, upload-time = "2025-05-19T14:14:02.396Z" },
- { url = "https://files.pythonhosted.org/packages/24/56/c77b5f36feef2ec92f1119756e468ac9c3eebc35aa8a4c9e51df664cbbc9/multidict-6.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9fcad2945b1b91c29ef2b4050f590bfcb68d8ac8e0995a74e659aa57e8d78e01", size = 225408, upload-time = "2025-05-19T14:14:04.826Z" },
- { url = "https://files.pythonhosted.org/packages/cc/b3/e8189b82af9b198b47bc637766208fc917189eea91d674bad417e657bbdf/multidict-6.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d877447e7368c7320832acb7159557e49b21ea10ffeb135c1077dbbc0816b598", size = 214396, upload-time = "2025-05-19T14:14:06.187Z" },
- { url = "https://files.pythonhosted.org/packages/20/e0/200d14c84e35ae13ee99fd65dc106e1a1acb87a301f15e906fc7d5b30c17/multidict-6.4.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:33a12ebac9f380714c298cbfd3e5b9c0c4e89c75fe612ae496512ee51028915f", size = 222237, upload-time = "2025-05-19T14:14:07.778Z" },
- { url = "https://files.pythonhosted.org/packages/13/f3/bb3df40045ca8262694a3245298732ff431dc781414a89a6a364ebac6840/multidict-6.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0f14ea68d29b43a9bf37953881b1e3eb75b2739e896ba4a6aa4ad4c5b9ffa145", size = 231425, upload-time = "2025-05-19T14:14:09.516Z" },
- { url = "https://files.pythonhosted.org/packages/85/3b/538563dc18514384dac169bcba938753ad9ab4d4c8d49b55d6ae49fb2579/multidict-6.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0327ad2c747a6600e4797d115d3c38a220fdb28e54983abe8964fd17e95ae83c", size = 226251, upload-time = "2025-05-19T14:14:10.82Z" },
- { url = "https://files.pythonhosted.org/packages/56/79/77e1a65513f09142358f1beb1d4cbc06898590b34a7de2e47023e3c5a3a2/multidict-6.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d1a20707492db9719a05fc62ee215fd2c29b22b47c1b1ba347f9abc831e26683", size = 220363, upload-time = "2025-05-19T14:14:12.638Z" },
- { url = "https://files.pythonhosted.org/packages/16/57/67b0516c3e348f8daaa79c369b3de4359a19918320ab82e2e586a1c624ef/multidict-6.4.4-cp310-cp310-win32.whl", hash = "sha256:d83f18315b9fca5db2452d1881ef20f79593c4aa824095b62cb280019ef7aa3d", size = 35175, upload-time = "2025-05-19T14:14:14.805Z" },
- { url = "https://files.pythonhosted.org/packages/86/5a/4ed8fec642d113fa653777cda30ef67aa5c8a38303c091e24c521278a6c6/multidict-6.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:9c17341ee04545fd962ae07330cb5a39977294c883485c8d74634669b1f7fe04", size = 38678, upload-time = "2025-05-19T14:14:16.949Z" },
- { url = "https://files.pythonhosted.org/packages/19/1b/4c6e638195851524a63972c5773c7737bea7e47b1ba402186a37773acee2/multidict-6.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4f5f29794ac0e73d2a06ac03fd18870adc0135a9d384f4a306a951188ed02f95", size = 65515, upload-time = "2025-05-19T14:14:19.767Z" },
- { url = "https://files.pythonhosted.org/packages/25/d5/10e6bca9a44b8af3c7f920743e5fc0c2bcf8c11bf7a295d4cfe00b08fb46/multidict-6.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c04157266344158ebd57b7120d9b0b35812285d26d0e78193e17ef57bfe2979a", size = 38609, upload-time = "2025-05-19T14:14:21.538Z" },
- { url = "https://files.pythonhosted.org/packages/26/b4/91fead447ccff56247edc7f0535fbf140733ae25187a33621771ee598a18/multidict-6.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bb61ffd3ab8310d93427e460f565322c44ef12769f51f77277b4abad7b6f7223", size = 37871, upload-time = "2025-05-19T14:14:22.666Z" },
- { url = "https://files.pythonhosted.org/packages/3b/37/cbc977cae59277e99d15bbda84cc53b5e0c4929ffd91d958347200a42ad0/multidict-6.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e0ba18a9afd495f17c351d08ebbc4284e9c9f7971d715f196b79636a4d0de44", size = 226661, upload-time = "2025-05-19T14:14:24.124Z" },
- { url = "https://files.pythonhosted.org/packages/15/cd/7e0b57fbd4dc2fc105169c4ecce5be1a63970f23bb4ec8c721b67e11953d/multidict-6.4.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9faf1b1dcaadf9f900d23a0e6d6c8eadd6a95795a0e57fcca73acce0eb912065", size = 223422, upload-time = "2025-05-19T14:14:25.437Z" },
- { url = "https://files.pythonhosted.org/packages/f1/01/1de268da121bac9f93242e30cd3286f6a819e5f0b8896511162d6ed4bf8d/multidict-6.4.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a4d1cb1327c6082c4fce4e2a438483390964c02213bc6b8d782cf782c9b1471f", size = 235447, upload-time = "2025-05-19T14:14:26.793Z" },
- { url = "https://files.pythonhosted.org/packages/d2/8c/8b9a5e4aaaf4f2de14e86181a3a3d7b105077f668b6a06f043ec794f684c/multidict-6.4.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:941f1bec2f5dbd51feeb40aea654c2747f811ab01bdd3422a48a4e4576b7d76a", size = 231455, upload-time = "2025-05-19T14:14:28.149Z" },
- { url = "https://files.pythonhosted.org/packages/35/db/e1817dcbaa10b319c412769cf999b1016890849245d38905b73e9c286862/multidict-6.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5f8a146184da7ea12910a4cec51ef85e44f6268467fb489c3caf0cd512f29c2", size = 223666, upload-time = "2025-05-19T14:14:29.584Z" },
- { url = "https://files.pythonhosted.org/packages/4a/e1/66e8579290ade8a00e0126b3d9a93029033ffd84f0e697d457ed1814d0fc/multidict-6.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:232b7237e57ec3c09be97206bfb83a0aa1c5d7d377faa019c68a210fa35831f1", size = 217392, upload-time = "2025-05-19T14:14:30.961Z" },
- { url = "https://files.pythonhosted.org/packages/7b/6f/f8639326069c24a48c7747c2a5485d37847e142a3f741ff3340c88060a9a/multidict-6.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:55ae0721c1513e5e3210bca4fc98456b980b0c2c016679d3d723119b6b202c42", size = 228969, upload-time = "2025-05-19T14:14:32.672Z" },
- { url = "https://files.pythonhosted.org/packages/d2/c3/3d58182f76b960eeade51c89fcdce450f93379340457a328e132e2f8f9ed/multidict-6.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:51d662c072579f63137919d7bb8fc250655ce79f00c82ecf11cab678f335062e", size = 217433, upload-time = "2025-05-19T14:14:34.016Z" },
- { url = "https://files.pythonhosted.org/packages/e1/4b/f31a562906f3bd375f3d0e83ce314e4a660c01b16c2923e8229b53fba5d7/multidict-6.4.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0e05c39962baa0bb19a6b210e9b1422c35c093b651d64246b6c2e1a7e242d9fd", size = 225418, upload-time = "2025-05-19T14:14:35.376Z" },
- { url = "https://files.pythonhosted.org/packages/99/89/78bb95c89c496d64b5798434a3deee21996114d4d2c28dd65850bf3a691e/multidict-6.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5b1cc3ab8c31d9ebf0faa6e3540fb91257590da330ffe6d2393d4208e638925", size = 235042, upload-time = "2025-05-19T14:14:36.723Z" },
- { url = "https://files.pythonhosted.org/packages/74/91/8780a6e5885a8770442a8f80db86a0887c4becca0e5a2282ba2cae702bc4/multidict-6.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:93ec84488a384cd7b8a29c2c7f467137d8a73f6fe38bb810ecf29d1ade011a7c", size = 230280, upload-time = "2025-05-19T14:14:38.194Z" },
- { url = "https://files.pythonhosted.org/packages/68/c1/fcf69cabd542eb6f4b892469e033567ee6991d361d77abdc55e3a0f48349/multidict-6.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b308402608493638763abc95f9dc0030bbd6ac6aff784512e8ac3da73a88af08", size = 223322, upload-time = "2025-05-19T14:14:40.015Z" },
- { url = "https://files.pythonhosted.org/packages/b8/85/5b80bf4b83d8141bd763e1d99142a9cdfd0db83f0739b4797172a4508014/multidict-6.4.4-cp311-cp311-win32.whl", hash = "sha256:343892a27d1a04d6ae455ecece12904d242d299ada01633d94c4f431d68a8c49", size = 35070, upload-time = "2025-05-19T14:14:41.904Z" },
- { url = "https://files.pythonhosted.org/packages/09/66/0bed198ffd590ab86e001f7fa46b740d58cf8ff98c2f254e4a36bf8861ad/multidict-6.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:73484a94f55359780c0f458bbd3c39cb9cf9c182552177d2136e828269dee529", size = 38667, upload-time = "2025-05-19T14:14:43.534Z" },
- { url = "https://files.pythonhosted.org/packages/d2/b5/5675377da23d60875fe7dae6be841787755878e315e2f517235f22f59e18/multidict-6.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc388f75a1c00000824bf28b7633e40854f4127ede80512b44c3cfeeea1839a2", size = 64293, upload-time = "2025-05-19T14:14:44.724Z" },
- { url = "https://files.pythonhosted.org/packages/34/a7/be384a482754bb8c95d2bbe91717bf7ccce6dc38c18569997a11f95aa554/multidict-6.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:98af87593a666f739d9dba5d0ae86e01b0e1a9cfcd2e30d2d361fbbbd1a9162d", size = 38096, upload-time = "2025-05-19T14:14:45.95Z" },
- { url = "https://files.pythonhosted.org/packages/66/6d/d59854bb4352306145bdfd1704d210731c1bb2c890bfee31fb7bbc1c4c7f/multidict-6.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aff4cafea2d120327d55eadd6b7f1136a8e5a0ecf6fb3b6863e8aca32cd8e50a", size = 37214, upload-time = "2025-05-19T14:14:47.158Z" },
- { url = "https://files.pythonhosted.org/packages/99/e0/c29d9d462d7cfc5fc8f9bf24f9c6843b40e953c0b55e04eba2ad2cf54fba/multidict-6.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:169c4ba7858176b797fe551d6e99040c531c775d2d57b31bcf4de6d7a669847f", size = 224686, upload-time = "2025-05-19T14:14:48.366Z" },
- { url = "https://files.pythonhosted.org/packages/dc/4a/da99398d7fd8210d9de068f9a1b5f96dfaf67d51e3f2521f17cba4ee1012/multidict-6.4.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b9eb4c59c54421a32b3273d4239865cb14ead53a606db066d7130ac80cc8ec93", size = 231061, upload-time = "2025-05-19T14:14:49.952Z" },
- { url = "https://files.pythonhosted.org/packages/21/f5/ac11add39a0f447ac89353e6ca46666847051103649831c08a2800a14455/multidict-6.4.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7cf3bd54c56aa16fdb40028d545eaa8d051402b61533c21e84046e05513d5780", size = 232412, upload-time = "2025-05-19T14:14:51.812Z" },
- { url = "https://files.pythonhosted.org/packages/d9/11/4b551e2110cded705a3c13a1d4b6a11f73891eb5a1c449f1b2b6259e58a6/multidict-6.4.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f682c42003c7264134bfe886376299db4cc0c6cd06a3295b41b347044bcb5482", size = 231563, upload-time = "2025-05-19T14:14:53.262Z" },
- { url = "https://files.pythonhosted.org/packages/4c/02/751530c19e78fe73b24c3da66618eda0aa0d7f6e7aa512e46483de6be210/multidict-6.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a920f9cf2abdf6e493c519492d892c362007f113c94da4c239ae88429835bad1", size = 223811, upload-time = "2025-05-19T14:14:55.232Z" },
- { url = "https://files.pythonhosted.org/packages/c7/cb/2be8a214643056289e51ca356026c7b2ce7225373e7a1f8c8715efee8988/multidict-6.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:530d86827a2df6504526106b4c104ba19044594f8722d3e87714e847c74a0275", size = 216524, upload-time = "2025-05-19T14:14:57.226Z" },
- { url = "https://files.pythonhosted.org/packages/19/f3/6d5011ec375c09081f5250af58de85f172bfcaafebff286d8089243c4bd4/multidict-6.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ecde56ea2439b96ed8a8d826b50c57364612ddac0438c39e473fafad7ae1c23b", size = 229012, upload-time = "2025-05-19T14:14:58.597Z" },
- { url = "https://files.pythonhosted.org/packages/67/9c/ca510785df5cf0eaf5b2a8132d7d04c1ce058dcf2c16233e596ce37a7f8e/multidict-6.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:dc8c9736d8574b560634775ac0def6bdc1661fc63fa27ffdfc7264c565bcb4f2", size = 226765, upload-time = "2025-05-19T14:15:00.048Z" },
- { url = "https://files.pythonhosted.org/packages/36/c8/ca86019994e92a0f11e642bda31265854e6ea7b235642f0477e8c2e25c1f/multidict-6.4.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7f3d3b3c34867579ea47cbd6c1f2ce23fbfd20a273b6f9e3177e256584f1eacc", size = 222888, upload-time = "2025-05-19T14:15:01.568Z" },
- { url = "https://files.pythonhosted.org/packages/c6/67/bc25a8e8bd522935379066950ec4e2277f9b236162a73548a2576d4b9587/multidict-6.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:87a728af265e08f96b6318ebe3c0f68b9335131f461efab2fc64cc84a44aa6ed", size = 234041, upload-time = "2025-05-19T14:15:03.759Z" },
- { url = "https://files.pythonhosted.org/packages/f1/a0/70c4c2d12857fccbe607b334b7ee28b6b5326c322ca8f73ee54e70d76484/multidict-6.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9f193eeda1857f8e8d3079a4abd258f42ef4a4bc87388452ed1e1c4d2b0c8740", size = 231046, upload-time = "2025-05-19T14:15:05.698Z" },
- { url = "https://files.pythonhosted.org/packages/c1/0f/52954601d02d39742aab01d6b92f53c1dd38b2392248154c50797b4df7f1/multidict-6.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be06e73c06415199200e9a2324a11252a3d62030319919cde5e6950ffeccf72e", size = 227106, upload-time = "2025-05-19T14:15:07.124Z" },
- { url = "https://files.pythonhosted.org/packages/af/24/679d83ec4379402d28721790dce818e5d6b9f94ce1323a556fb17fa9996c/multidict-6.4.4-cp312-cp312-win32.whl", hash = "sha256:622f26ea6a7e19b7c48dd9228071f571b2fbbd57a8cd71c061e848f281550e6b", size = 35351, upload-time = "2025-05-19T14:15:08.556Z" },
- { url = "https://files.pythonhosted.org/packages/52/ef/40d98bc5f986f61565f9b345f102409534e29da86a6454eb6b7c00225a13/multidict-6.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:5e2bcda30d5009996ff439e02a9f2b5c3d64a20151d34898c000a6281faa3781", size = 38791, upload-time = "2025-05-19T14:15:09.825Z" },
- { url = "https://files.pythonhosted.org/packages/df/2a/e166d2ffbf4b10131b2d5b0e458f7cee7d986661caceae0de8753042d4b2/multidict-6.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:82ffabefc8d84c2742ad19c37f02cde5ec2a1ee172d19944d380f920a340e4b9", size = 64123, upload-time = "2025-05-19T14:15:11.044Z" },
- { url = "https://files.pythonhosted.org/packages/8c/96/e200e379ae5b6f95cbae472e0199ea98913f03d8c9a709f42612a432932c/multidict-6.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6a2f58a66fe2c22615ad26156354005391e26a2f3721c3621504cd87c1ea87bf", size = 38049, upload-time = "2025-05-19T14:15:12.902Z" },
- { url = "https://files.pythonhosted.org/packages/75/fb/47afd17b83f6a8c7fa863c6d23ac5ba6a0e6145ed8a6bcc8da20b2b2c1d2/multidict-6.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5883d6ee0fd9d8a48e9174df47540b7545909841ac82354c7ae4cbe9952603bd", size = 37078, upload-time = "2025-05-19T14:15:14.282Z" },
- { url = "https://files.pythonhosted.org/packages/fa/70/1af3143000eddfb19fd5ca5e78393985ed988ac493bb859800fe0914041f/multidict-6.4.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9abcf56a9511653fa1d052bfc55fbe53dbee8f34e68bd6a5a038731b0ca42d15", size = 224097, upload-time = "2025-05-19T14:15:15.566Z" },
- { url = "https://files.pythonhosted.org/packages/b1/39/d570c62b53d4fba844e0378ffbcd02ac25ca423d3235047013ba2f6f60f8/multidict-6.4.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6ed5ae5605d4ad5a049fad2a28bb7193400700ce2f4ae484ab702d1e3749c3f9", size = 230768, upload-time = "2025-05-19T14:15:17.308Z" },
- { url = "https://files.pythonhosted.org/packages/fd/f8/ed88f2c4d06f752b015933055eb291d9bc184936903752c66f68fb3c95a7/multidict-6.4.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbfcb60396f9bcfa63e017a180c3105b8c123a63e9d1428a36544e7d37ca9e20", size = 231331, upload-time = "2025-05-19T14:15:18.73Z" },
- { url = "https://files.pythonhosted.org/packages/9c/6f/8e07cffa32f483ab887b0d56bbd8747ac2c1acd00dc0af6fcf265f4a121e/multidict-6.4.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0f1987787f5f1e2076b59692352ab29a955b09ccc433c1f6b8e8e18666f608b", size = 230169, upload-time = "2025-05-19T14:15:20.179Z" },
- { url = "https://files.pythonhosted.org/packages/e6/2b/5dcf173be15e42f330110875a2668ddfc208afc4229097312212dc9c1236/multidict-6.4.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d0121ccce8c812047d8d43d691a1ad7641f72c4f730474878a5aeae1b8ead8c", size = 222947, upload-time = "2025-05-19T14:15:21.714Z" },
- { url = "https://files.pythonhosted.org/packages/39/75/4ddcbcebe5ebcd6faa770b629260d15840a5fc07ce8ad295a32e14993726/multidict-6.4.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83ec4967114295b8afd120a8eec579920c882831a3e4c3331d591a8e5bfbbc0f", size = 215761, upload-time = "2025-05-19T14:15:23.242Z" },
- { url = "https://files.pythonhosted.org/packages/6a/c9/55e998ae45ff15c5608e384206aa71a11e1b7f48b64d166db400b14a3433/multidict-6.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:995f985e2e268deaf17867801b859a282e0448633f1310e3704b30616d269d69", size = 227605, upload-time = "2025-05-19T14:15:24.763Z" },
- { url = "https://files.pythonhosted.org/packages/04/49/c2404eac74497503c77071bd2e6f88c7e94092b8a07601536b8dbe99be50/multidict-6.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d832c608f94b9f92a0ec8b7e949be7792a642b6e535fcf32f3e28fab69eeb046", size = 226144, upload-time = "2025-05-19T14:15:26.249Z" },
- { url = "https://files.pythonhosted.org/packages/62/c5/0cd0c3c6f18864c40846aa2252cd69d308699cb163e1c0d989ca301684da/multidict-6.4.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d21c1212171cf7da703c5b0b7a0e85be23b720818aef502ad187d627316d5645", size = 221100, upload-time = "2025-05-19T14:15:28.303Z" },
- { url = "https://files.pythonhosted.org/packages/71/7b/f2f3887bea71739a046d601ef10e689528d4f911d84da873b6be9194ffea/multidict-6.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:cbebaa076aaecad3d4bb4c008ecc73b09274c952cf6a1b78ccfd689e51f5a5b0", size = 232731, upload-time = "2025-05-19T14:15:30.263Z" },
- { url = "https://files.pythonhosted.org/packages/e5/b3/d9de808349df97fa75ec1372758701b5800ebad3c46ae377ad63058fbcc6/multidict-6.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c93a6fb06cc8e5d3628b2b5fda215a5db01e8f08fc15fadd65662d9b857acbe4", size = 229637, upload-time = "2025-05-19T14:15:33.337Z" },
- { url = "https://files.pythonhosted.org/packages/5e/57/13207c16b615eb4f1745b44806a96026ef8e1b694008a58226c2d8f5f0a5/multidict-6.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8cd8f81f1310182362fb0c7898145ea9c9b08a71081c5963b40ee3e3cac589b1", size = 225594, upload-time = "2025-05-19T14:15:34.832Z" },
- { url = "https://files.pythonhosted.org/packages/3a/e4/d23bec2f70221604f5565000632c305fc8f25ba953e8ce2d8a18842b9841/multidict-6.4.4-cp313-cp313-win32.whl", hash = "sha256:3e9f1cd61a0ab857154205fb0b1f3d3ace88d27ebd1409ab7af5096e409614cd", size = 35359, upload-time = "2025-05-19T14:15:36.246Z" },
- { url = "https://files.pythonhosted.org/packages/a7/7a/cfe1a47632be861b627f46f642c1d031704cc1c0f5c0efbde2ad44aa34bd/multidict-6.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:8ffb40b74400e4455785c2fa37eba434269149ec525fc8329858c862e4b35373", size = 38903, upload-time = "2025-05-19T14:15:37.507Z" },
- { url = "https://files.pythonhosted.org/packages/68/7b/15c259b0ab49938a0a1c8f3188572802704a779ddb294edc1b2a72252e7c/multidict-6.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6a602151dbf177be2450ef38966f4be3467d41a86c6a845070d12e17c858a156", size = 68895, upload-time = "2025-05-19T14:15:38.856Z" },
- { url = "https://files.pythonhosted.org/packages/f1/7d/168b5b822bccd88142e0a3ce985858fea612404edd228698f5af691020c9/multidict-6.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d2b9712211b860d123815a80b859075d86a4d54787e247d7fbee9db6832cf1c", size = 40183, upload-time = "2025-05-19T14:15:40.197Z" },
- { url = "https://files.pythonhosted.org/packages/e0/b7/d4b8d98eb850ef28a4922ba508c31d90715fd9b9da3801a30cea2967130b/multidict-6.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d2fa86af59f8fc1972e121ade052145f6da22758f6996a197d69bb52f8204e7e", size = 39592, upload-time = "2025-05-19T14:15:41.508Z" },
- { url = "https://files.pythonhosted.org/packages/18/28/a554678898a19583548e742080cf55d169733baf57efc48c2f0273a08583/multidict-6.4.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50855d03e9e4d66eab6947ba688ffb714616f985838077bc4b490e769e48da51", size = 226071, upload-time = "2025-05-19T14:15:42.877Z" },
- { url = "https://files.pythonhosted.org/packages/ee/dc/7ba6c789d05c310e294f85329efac1bf5b450338d2542498db1491a264df/multidict-6.4.4-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5bce06b83be23225be1905dcdb6b789064fae92499fbc458f59a8c0e68718601", size = 222597, upload-time = "2025-05-19T14:15:44.412Z" },
- { url = "https://files.pythonhosted.org/packages/24/4f/34eadbbf401b03768dba439be0fb94b0d187facae9142821a3d5599ccb3b/multidict-6.4.4-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66ed0731f8e5dfd8369a883b6e564aca085fb9289aacabd9decd70568b9a30de", size = 228253, upload-time = "2025-05-19T14:15:46.474Z" },
- { url = "https://files.pythonhosted.org/packages/c0/e6/493225a3cdb0d8d80d43a94503fc313536a07dae54a3f030d279e629a2bc/multidict-6.4.4-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:329ae97fc2f56f44d91bc47fe0972b1f52d21c4b7a2ac97040da02577e2daca2", size = 226146, upload-time = "2025-05-19T14:15:48.003Z" },
- { url = "https://files.pythonhosted.org/packages/2f/70/e411a7254dc3bff6f7e6e004303b1b0591358e9f0b7c08639941e0de8bd6/multidict-6.4.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c27e5dcf520923d6474d98b96749e6805f7677e93aaaf62656005b8643f907ab", size = 220585, upload-time = "2025-05-19T14:15:49.546Z" },
- { url = "https://files.pythonhosted.org/packages/08/8f/beb3ae7406a619100d2b1fb0022c3bb55a8225ab53c5663648ba50dfcd56/multidict-6.4.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:058cc59b9e9b143cc56715e59e22941a5d868c322242278d28123a5d09cdf6b0", size = 212080, upload-time = "2025-05-19T14:15:51.151Z" },
- { url = "https://files.pythonhosted.org/packages/9c/ec/355124e9d3d01cf8edb072fd14947220f357e1c5bc79c88dff89297e9342/multidict-6.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:69133376bc9a03f8c47343d33f91f74a99c339e8b58cea90433d8e24bb298031", size = 226558, upload-time = "2025-05-19T14:15:52.665Z" },
- { url = "https://files.pythonhosted.org/packages/fd/22/d2b95cbebbc2ada3be3812ea9287dcc9712d7f1a012fad041770afddb2ad/multidict-6.4.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:d6b15c55721b1b115c5ba178c77104123745b1417527ad9641a4c5e2047450f0", size = 212168, upload-time = "2025-05-19T14:15:55.279Z" },
- { url = "https://files.pythonhosted.org/packages/4d/c5/62bfc0b2f9ce88326dbe7179f9824a939c6c7775b23b95de777267b9725c/multidict-6.4.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a887b77f51d3d41e6e1a63cf3bc7ddf24de5939d9ff69441387dfefa58ac2e26", size = 217970, upload-time = "2025-05-19T14:15:56.806Z" },
- { url = "https://files.pythonhosted.org/packages/79/74/977cea1aadc43ff1c75d23bd5bc4768a8fac98c14e5878d6ee8d6bab743c/multidict-6.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:632a3bf8f1787f7ef7d3c2f68a7bde5be2f702906f8b5842ad6da9d974d0aab3", size = 226980, upload-time = "2025-05-19T14:15:58.313Z" },
- { url = "https://files.pythonhosted.org/packages/48/fc/cc4a1a2049df2eb84006607dc428ff237af38e0fcecfdb8a29ca47b1566c/multidict-6.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a145c550900deb7540973c5cdb183b0d24bed6b80bf7bddf33ed8f569082535e", size = 220641, upload-time = "2025-05-19T14:15:59.866Z" },
- { url = "https://files.pythonhosted.org/packages/3b/6a/a7444d113ab918701988d4abdde373dbdfd2def7bd647207e2bf645c7eac/multidict-6.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc5d83c6619ca5c9672cb78b39ed8542f1975a803dee2cda114ff73cbb076edd", size = 221728, upload-time = "2025-05-19T14:16:01.535Z" },
- { url = "https://files.pythonhosted.org/packages/2b/b0/fdf4c73ad1c55e0f4dbbf2aa59dd37037334091f9a4961646d2b7ac91a86/multidict-6.4.4-cp313-cp313t-win32.whl", hash = "sha256:3312f63261b9df49be9d57aaa6abf53a6ad96d93b24f9cc16cf979956355ce6e", size = 41913, upload-time = "2025-05-19T14:16:03.199Z" },
- { url = "https://files.pythonhosted.org/packages/8e/92/27989ecca97e542c0d01d05a98a5ae12198a243a9ee12563a0313291511f/multidict-6.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:ba852168d814b2c73333073e1c7116d9395bea69575a01b0b3c89d2d5a87c8fb", size = 46112, upload-time = "2025-05-19T14:16:04.909Z" },
- { url = "https://files.pythonhosted.org/packages/18/5c/92607a79e7fd0361c90b3c5d79bbd186e3968e8a4832dbefcd7808f1c823/multidict-6.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:603f39bd1cf85705c6c1ba59644b480dfe495e6ee2b877908de93322705ad7cf", size = 66007, upload-time = "2025-05-19T14:16:06.25Z" },
- { url = "https://files.pythonhosted.org/packages/32/1e/212a154926a9290d8ae432e761d1c98ed95fccce84b1b938eaf1bf17378e/multidict-6.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fc60f91c02e11dfbe3ff4e1219c085695c339af72d1641800fe6075b91850c8f", size = 38824, upload-time = "2025-05-19T14:16:07.61Z" },
- { url = "https://files.pythonhosted.org/packages/8b/64/5ca6fb5dbc7d5aa352cd2d013c86ae44133c3f4f6b83a80dacd42ee5c568/multidict-6.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:496bcf01c76a70a31c3d746fd39383aad8d685ce6331e4c709e9af4ced5fa221", size = 38117, upload-time = "2025-05-19T14:16:08.966Z" },
- { url = "https://files.pythonhosted.org/packages/aa/20/3aee7910260e7b6f0045b6f48b97ebf041de0cab513c12f87cf6e4e514d3/multidict-6.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4219390fb5bf8e548e77b428bb36a21d9382960db5321b74d9d9987148074d6b", size = 218106, upload-time = "2025-05-19T14:16:10.962Z" },
- { url = "https://files.pythonhosted.org/packages/a9/79/15f5a65b8de8ae8f3c5da1591a322620675e4fec8d39995b04101d2b2e2c/multidict-6.4.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef4e9096ff86dfdcbd4a78253090ba13b1d183daa11b973e842465d94ae1772", size = 213817, upload-time = "2025-05-19T14:16:12.486Z" },
- { url = "https://files.pythonhosted.org/packages/ab/a7/90de36db90ce2936fbb1639ca51508965861a8ad5dc2947531d18f3363b9/multidict-6.4.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:49a29d7133b1fc214e818bbe025a77cc6025ed9a4f407d2850373ddde07fd04a", size = 228133, upload-time = "2025-05-19T14:16:14.48Z" },
- { url = "https://files.pythonhosted.org/packages/df/25/5fcd66fda3c8b7d6d6f658a871017791c46824e965dfa20a4c46d4167ad4/multidict-6.4.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e32053d6d3a8b0dfe49fde05b496731a0e6099a4df92154641c00aa76786aef5", size = 224271, upload-time = "2025-05-19T14:16:16.314Z" },
- { url = "https://files.pythonhosted.org/packages/fd/9a/1011812091fd99b2dddd9d2dbde4b7d69bbf8070e0291fe49c3bb40c2d55/multidict-6.4.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cc403092a49509e8ef2d2fd636a8ecefc4698cc57bbe894606b14579bc2a955", size = 216448, upload-time = "2025-05-19T14:16:18.263Z" },
- { url = "https://files.pythonhosted.org/packages/cf/cc/916e066b7e2686999f95dde87f588be26fa1c2f05e70d9fd472fe2289c0b/multidict-6.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5363f9b2a7f3910e5c87d8b1855c478c05a2dc559ac57308117424dfaad6805c", size = 210080, upload-time = "2025-05-19T14:16:20.326Z" },
- { url = "https://files.pythonhosted.org/packages/f8/ff/15034b18f2e4179cd559aa13bc3b376a95c22e1fd7c3b88884e078ad5466/multidict-6.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2e543a40e4946cf70a88a3be87837a3ae0aebd9058ba49e91cacb0b2cd631e2b", size = 221926, upload-time = "2025-05-19T14:16:22.227Z" },
- { url = "https://files.pythonhosted.org/packages/17/43/4243298a6b0b869a83b6331f3fcc12a2a0544c0995292ee96badf0fec6aa/multidict-6.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:60d849912350da557fe7de20aa8cf394aada6980d0052cc829eeda4a0db1c1db", size = 211318, upload-time = "2025-05-19T14:16:23.914Z" },
- { url = "https://files.pythonhosted.org/packages/fe/80/bc43c87d60138e401c7d1818a47e5a0f748904c9f3be99012cdab5e31446/multidict-6.4.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:19d08b4f22eae45bb018b9f06e2838c1e4b853c67628ef8ae126d99de0da6395", size = 217611, upload-time = "2025-05-19T14:16:25.647Z" },
- { url = "https://files.pythonhosted.org/packages/1e/5d/2ec94209254e48910911ac2404d71b37f06fd97ec83948a92d0c87a11d3c/multidict-6.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d693307856d1ef08041e8b6ff01d5b4618715007d288490ce2c7e29013c12b9a", size = 227893, upload-time = "2025-05-19T14:16:27.721Z" },
- { url = "https://files.pythonhosted.org/packages/71/83/89344adc0cf08fd89d82d43de1a17a2635b03a57dfa680f6cdf2a24d481f/multidict-6.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fad6daaed41021934917f4fb03ca2db8d8a4d79bf89b17ebe77228eb6710c003", size = 221956, upload-time = "2025-05-19T14:16:29.307Z" },
- { url = "https://files.pythonhosted.org/packages/f0/ea/81382bb59cd3a1047d1c2ea9339d2107fc918a63491bbb9399eb1aceda91/multidict-6.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c10d17371bff801af0daf8b073c30b6cf14215784dc08cd5c43ab5b7b8029bbc", size = 216850, upload-time = "2025-05-19T14:16:30.913Z" },
- { url = "https://files.pythonhosted.org/packages/0f/90/c848d62de66c2958932ce155adae418cbf79d96cfaf992e5255819f8f1d9/multidict-6.4.4-cp39-cp39-win32.whl", hash = "sha256:7e23f2f841fcb3ebd4724a40032d32e0892fbba4143e43d2a9e7695c5e50e6bd", size = 35235, upload-time = "2025-05-19T14:16:32.85Z" },
- { url = "https://files.pythonhosted.org/packages/d4/19/dd625207c92889c1ae7b89fcbde760d99853265cfe7ffb0826393151acd1/multidict-6.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:4d7b50b673ffb4ff4366e7ab43cf1f0aef4bd3608735c5fbdf0bdb6f690da411", size = 38821, upload-time = "2025-05-19T14:16:34.288Z" },
- { url = "https://files.pythonhosted.org/packages/84/5d/e17845bb0fa76334477d5de38654d27946d5b5d3695443987a094a71b440/multidict-6.4.4-py3-none-any.whl", hash = "sha256:bd4557071b561a8b3b6075c3ce93cf9bfb6182cb241805c3d66ced3b75eff4ac", size = 10481, upload-time = "2025-05-19T14:16:36.024Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/63/7bdd4adc330abcca54c85728db2327130e49e52e8c3ce685cec44e0f2e9f/multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349", size = 77153, upload-time = "2025-10-06T14:48:26.409Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/bb/b6c35ff175ed1a3142222b78455ee31be71a8396ed3ab5280fbe3ebe4e85/multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e", size = 44993, upload-time = "2025-10-06T14:48:28.4Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/1f/064c77877c5fa6df6d346e68075c0f6998547afe952d6471b4c5f6a7345d/multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3", size = 44607, upload-time = "2025-10-06T14:48:29.581Z" },
+ { url = "https://files.pythonhosted.org/packages/04/7a/bf6aa92065dd47f287690000b3d7d332edfccb2277634cadf6a810463c6a/multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046", size = 241847, upload-time = "2025-10-06T14:48:32.107Z" },
+ { url = "https://files.pythonhosted.org/packages/94/39/297a8de920f76eda343e4ce05f3b489f0ab3f9504f2576dfb37b7c08ca08/multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32", size = 242616, upload-time = "2025-10-06T14:48:34.054Z" },
+ { url = "https://files.pythonhosted.org/packages/39/3a/d0eee2898cfd9d654aea6cb8c4addc2f9756e9a7e09391cfe55541f917f7/multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73", size = 222333, upload-time = "2025-10-06T14:48:35.9Z" },
+ { url = "https://files.pythonhosted.org/packages/05/48/3b328851193c7a4240815b71eea165b49248867bbb6153a0aee227a0bb47/multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc", size = 253239, upload-time = "2025-10-06T14:48:37.302Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/ca/0706a98c8d126a89245413225ca4a3fefc8435014de309cf8b30acb68841/multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62", size = 251618, upload-time = "2025-10-06T14:48:38.963Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/4f/9c7992f245554d8b173f6f0a048ad24b3e645d883f096857ec2c0822b8bd/multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84", size = 241655, upload-time = "2025-10-06T14:48:40.312Z" },
+ { url = "https://files.pythonhosted.org/packages/31/79/26a85991ae67efd1c0b1fc2e0c275b8a6aceeb155a68861f63f87a798f16/multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0", size = 239245, upload-time = "2025-10-06T14:48:41.848Z" },
+ { url = "https://files.pythonhosted.org/packages/14/1e/75fa96394478930b79d0302eaf9a6c69f34005a1a5251ac8b9c336486ec9/multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e", size = 233523, upload-time = "2025-10-06T14:48:43.749Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/5e/085544cb9f9c4ad2b5d97467c15f856df8d9bac410cffd5c43991a5d878b/multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4", size = 243129, upload-time = "2025-10-06T14:48:45.225Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/c3/e9d9e2f20c9474e7a8fcef28f863c5cbd29bb5adce6b70cebe8bdad0039d/multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648", size = 248999, upload-time = "2025-10-06T14:48:46.703Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/3f/df171b6efa3239ae33b97b887e42671cd1d94d460614bfb2c30ffdab3b95/multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111", size = 243711, upload-time = "2025-10-06T14:48:48.146Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/2f/9b5564888c4e14b9af64c54acf149263721a283aaf4aa0ae89b091d5d8c1/multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36", size = 237504, upload-time = "2025-10-06T14:48:49.447Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/3a/0bd6ca0f7d96d790542d591c8c3354c1e1b6bfd2024d4d92dc3d87485ec7/multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85", size = 41422, upload-time = "2025-10-06T14:48:50.789Z" },
+ { url = "https://files.pythonhosted.org/packages/00/35/f6a637ea2c75f0d3b7c7d41b1189189acff0d9deeb8b8f35536bb30f5e33/multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7", size = 46050, upload-time = "2025-10-06T14:48:51.938Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/b8/f7bf8329b39893d02d9d95cf610c75885d12fc0f402b1c894e1c8e01c916/multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0", size = 43153, upload-time = "2025-10-06T14:48:53.146Z" },
+ { url = "https://files.pythonhosted.org/packages/34/9e/5c727587644d67b2ed479041e4b1c58e30afc011e3d45d25bbe35781217c/multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc", size = 76604, upload-time = "2025-10-06T14:48:54.277Z" },
+ { url = "https://files.pythonhosted.org/packages/17/e4/67b5c27bd17c085a5ea8f1ec05b8a3e5cba0ca734bfcad5560fb129e70ca/multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721", size = 44715, upload-time = "2025-10-06T14:48:55.445Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/e1/866a5d77be6ea435711bef2a4291eed11032679b6b28b56b4776ab06ba3e/multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6", size = 44332, upload-time = "2025-10-06T14:48:56.706Z" },
+ { url = "https://files.pythonhosted.org/packages/31/61/0c2d50241ada71ff61a79518db85ada85fdabfcf395d5968dae1cbda04e5/multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c", size = 245212, upload-time = "2025-10-06T14:48:58.042Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/e0/919666a4e4b57fff1b57f279be1c9316e6cdc5de8a8b525d76f6598fefc7/multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7", size = 246671, upload-time = "2025-10-06T14:49:00.004Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/cc/d027d9c5a520f3321b65adea289b965e7bcbd2c34402663f482648c716ce/multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7", size = 225491, upload-time = "2025-10-06T14:49:01.393Z" },
+ { url = "https://files.pythonhosted.org/packages/75/c4/bbd633980ce6155a28ff04e6a6492dd3335858394d7bb752d8b108708558/multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9", size = 257322, upload-time = "2025-10-06T14:49:02.745Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/6d/d622322d344f1f053eae47e033b0b3f965af01212de21b10bcf91be991fb/multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8", size = 254694, upload-time = "2025-10-06T14:49:04.15Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/9f/78f8761c2705d4c6d7516faed63c0ebdac569f6db1bef95e0d5218fdc146/multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd", size = 246715, upload-time = "2025-10-06T14:49:05.967Z" },
+ { url = "https://files.pythonhosted.org/packages/78/59/950818e04f91b9c2b95aab3d923d9eabd01689d0dcd889563988e9ea0fd8/multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb", size = 243189, upload-time = "2025-10-06T14:49:07.37Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/3d/77c79e1934cad2ee74991840f8a0110966d9599b3af95964c0cd79bb905b/multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6", size = 237845, upload-time = "2025-10-06T14:49:08.759Z" },
+ { url = "https://files.pythonhosted.org/packages/63/1b/834ce32a0a97a3b70f86437f685f880136677ac00d8bce0027e9fd9c2db7/multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2", size = 246374, upload-time = "2025-10-06T14:49:10.574Z" },
+ { url = "https://files.pythonhosted.org/packages/23/ef/43d1c3ba205b5dec93dc97f3fba179dfa47910fc73aaaea4f7ceb41cec2a/multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff", size = 253345, upload-time = "2025-10-06T14:49:12.331Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/03/eaf95bcc2d19ead522001f6a650ef32811aa9e3624ff0ad37c445c7a588c/multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b", size = 246940, upload-time = "2025-10-06T14:49:13.821Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/df/ec8a5fd66ea6cd6f525b1fcbb23511b033c3e9bc42b81384834ffa484a62/multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34", size = 242229, upload-time = "2025-10-06T14:49:15.603Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/a2/59b405d59fd39ec86d1142630e9049243015a5f5291ba49cadf3c090c541/multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff", size = 41308, upload-time = "2025-10-06T14:49:16.871Z" },
+ { url = "https://files.pythonhosted.org/packages/32/0f/13228f26f8b882c34da36efa776c3b7348455ec383bab4a66390e42963ae/multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81", size = 46037, upload-time = "2025-10-06T14:49:18.457Z" },
+ { url = "https://files.pythonhosted.org/packages/84/1f/68588e31b000535a3207fd3c909ebeec4fb36b52c442107499c18a896a2a/multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912", size = 43023, upload-time = "2025-10-06T14:49:19.648Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" },
+ { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545, upload-time = "2025-10-06T14:49:24.882Z" },
+ { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305, upload-time = "2025-10-06T14:49:26.778Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363, upload-time = "2025-10-06T14:49:28.562Z" },
+ { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375, upload-time = "2025-10-06T14:49:29.96Z" },
+ { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346, upload-time = "2025-10-06T14:49:31.404Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107, upload-time = "2025-10-06T14:49:32.974Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592, upload-time = "2025-10-06T14:49:34.52Z" },
+ { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024, upload-time = "2025-10-06T14:49:35.956Z" },
+ { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484, upload-time = "2025-10-06T14:49:37.631Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579, upload-time = "2025-10-06T14:49:39.502Z" },
+ { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654, upload-time = "2025-10-06T14:49:41.32Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511, upload-time = "2025-10-06T14:49:46.021Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895, upload-time = "2025-10-06T14:49:48.718Z" },
+ { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073, upload-time = "2025-10-06T14:49:50.28Z" },
+ { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226, upload-time = "2025-10-06T14:49:52.304Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" },
+ { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" },
+ { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" },
+ { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" },
+ { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" },
+ { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" },
+ { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" },
+ { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" },
+ { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" },
+ { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" },
+ { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" },
+ { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" },
+ { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" },
+ { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" },
+ { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" },
+ { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" },
+ { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" },
+ { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" },
+ { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" },
+ { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" },
+ { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" },
+ { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" },
+ { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" },
+ { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" },
+ { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" },
+ { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" },
+ { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" },
+ { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" },
+ { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" },
+ { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" },
+ { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" },
+ { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" },
+ { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" },
+ { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" },
+ { url = "https://files.pythonhosted.org/packages/90/d7/4cf84257902265c4250769ac49f4eaab81c182ee9aff8bf59d2714dbb174/multidict-6.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:363eb68a0a59bd2303216d2346e6c441ba10d36d1f9969fcb6f1ba700de7bb5c", size = 77073, upload-time = "2025-10-06T14:51:57.386Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/51/194e999630a656e76c2965a1590d12faa5cd528170f2abaa04423e09fe8d/multidict-6.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d874eb056410ca05fed180b6642e680373688efafc7f077b2a2f61811e873a40", size = 44928, upload-time = "2025-10-06T14:51:58.791Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/6b/2a195373c33068c9158e0941d0b46cfcc9c1d894ca2eb137d1128081dff0/multidict-6.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b55d5497b51afdfde55925e04a022f1de14d4f4f25cdfd4f5d9b0aa96166851", size = 44581, upload-time = "2025-10-06T14:52:00.174Z" },
+ { url = "https://files.pythonhosted.org/packages/69/7b/7f4f2e644b6978bf011a5fd9a5ebb7c21de3f38523b1f7897d36a1ac1311/multidict-6.7.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f8e5c0031b90ca9ce555e2e8fd5c3b02a25f14989cbc310701823832c99eb687", size = 239901, upload-time = "2025-10-06T14:52:02.416Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/b5/952c72786710a031aa204a9adf7db66d7f97a2c6573889d58b9e60fe6702/multidict-6.7.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cf41880c991716f3c7cec48e2f19ae4045fc9db5fc9cff27347ada24d710bb5", size = 240534, upload-time = "2025-10-06T14:52:04.105Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/ef/109fe1f2471e4c458c74242c7e4a833f2d9fc8a6813cd7ee345b0bad18f9/multidict-6.7.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cfc12a8630a29d601f48d47787bd7eb730e475e83edb5d6c5084317463373eb", size = 219545, upload-time = "2025-10-06T14:52:06.208Z" },
+ { url = "https://files.pythonhosted.org/packages/42/bd/327d91288114967f9fe90dc53de70aa3fec1b9073e46aa32c4828f771a87/multidict-6.7.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3996b50c3237c4aec17459217c1e7bbdead9a22a0fcd3c365564fbd16439dde6", size = 251187, upload-time = "2025-10-06T14:52:08.049Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/13/a8b078ebbaceb7819fd28cd004413c33b98f1b70d542a62e6a00b74fb09f/multidict-6.7.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7f5170993a0dd3ab871c74f45c0a21a4e2c37a2f2b01b5f722a2ad9c6650469e", size = 249379, upload-time = "2025-10-06T14:52:09.831Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/6d/ab12e1246be4d65d1f55de1e6f6aaa9b8120eddcfdd1d290439c7833d5ce/multidict-6.7.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec81878ddf0e98817def1e77d4f50dae5ef5b0e4fe796fae3bd674304172416e", size = 239241, upload-time = "2025-10-06T14:52:11.561Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/d7/079a93625208c173b8fa756396814397c0fd9fee61ef87b75a748820b86e/multidict-6.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9281bf5b34f59afbc6b1e477a372e9526b66ca446f4bf62592839c195a718b32", size = 237418, upload-time = "2025-10-06T14:52:13.671Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/29/03777c2212274aa9440918d604dc9d6af0e6b4558c611c32c3dcf1a13870/multidict-6.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:68af405971779d8b37198726f2b6fe3955db846fee42db7a4286fc542203934c", size = 232987, upload-time = "2025-10-06T14:52:15.708Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/00/11188b68d85a84e8050ee34724d6ded19ad03975caebe0c8dcb2829b37bf/multidict-6.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3ba3ef510467abb0667421a286dc906e30eb08569365f5cdb131d7aff7c2dd84", size = 240985, upload-time = "2025-10-06T14:52:17.317Z" },
+ { url = "https://files.pythonhosted.org/packages/df/0c/12eef6aeda21859c6cdf7d75bd5516d83be3efe3d8cc45fd1a3037f5b9dc/multidict-6.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b61189b29081a20c7e4e0b49b44d5d44bb0dc92be3c6d06a11cc043f81bf9329", size = 246855, upload-time = "2025-10-06T14:52:19.096Z" },
+ { url = "https://files.pythonhosted.org/packages/69/f6/076120fd8bb3975f09228e288e08bff6b9f1bfd5166397c7ba284f622ab2/multidict-6.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fb287618b9c7aa3bf8d825f02d9201b2f13078a5ed3b293c8f4d953917d84d5e", size = 241804, upload-time = "2025-10-06T14:52:21.166Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/51/41bb950c81437b88a93e6ddfca1d8763569ae861e638442838c4375f7497/multidict-6.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:521f33e377ff64b96c4c556b81c55d0cfffb96a11c194fd0c3f1e56f3d8dd5a4", size = 235321, upload-time = "2025-10-06T14:52:23.208Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cf/5bbd31f055199d56c1f6b04bbadad3ccb24e6d5d4db75db774fc6d6674b8/multidict-6.7.0-cp39-cp39-win32.whl", hash = "sha256:ce8fdc2dca699f8dbf055a61d73eaa10482569ad20ee3c36ef9641f69afa8c91", size = 41435, upload-time = "2025-10-06T14:52:24.735Z" },
+ { url = "https://files.pythonhosted.org/packages/af/01/547ffe9c2faec91c26965c152f3fea6cff068b6037401f61d310cc861ff4/multidict-6.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:7e73299c99939f089dd9b2120a04a516b95cdf8c1cd2b18c53ebf0de80b1f18f", size = 46193, upload-time = "2025-10-06T14:52:26.101Z" },
+ { url = "https://files.pythonhosted.org/packages/27/77/cfa5461d1d2651d6fc24216c92b4a21d4e385a41c46e0d9f3b070675167b/multidict-6.7.0-cp39-cp39-win_arm64.whl", hash = "sha256:6bdce131e14b04fd34a809b6380dbfd826065c3e2fe8a50dbae659fa0c390546", size = 43118, upload-time = "2025-10-06T14:52:27.876Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" },
]
[[package]]
@@ -954,11 +1486,27 @@ wheels = [
name = "packaging"
version = "24.2"
source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.10'",
+]
sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" },
]
+[[package]]
+name = "packaging"
+version = "25.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14'",
+ "python_full_version >= '3.10' and python_full_version < '3.14'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
+]
+
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -970,107 +1518,131 @@ wheels = [
[[package]]
name = "propcache"
-version = "0.3.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/07/c8/fdc6686a986feae3541ea23dcaa661bd93972d3940460646c6bb96e21c40/propcache-0.3.1.tar.gz", hash = "sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf", size = 43651, upload-time = "2025-03-26T03:06:12.05Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/20/56/e27c136101addf877c8291dbda1b3b86ae848f3837ce758510a0d806c92f/propcache-0.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f27785888d2fdd918bc36de8b8739f2d6c791399552333721b58193f68ea3e98", size = 80224, upload-time = "2025-03-26T03:03:35.81Z" },
- { url = "https://files.pythonhosted.org/packages/63/bd/88e98836544c4f04db97eefd23b037c2002fa173dd2772301c61cd3085f9/propcache-0.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4e89cde74154c7b5957f87a355bb9c8ec929c167b59c83d90654ea36aeb6180", size = 46491, upload-time = "2025-03-26T03:03:38.107Z" },
- { url = "https://files.pythonhosted.org/packages/15/43/0b8eb2a55753c4a574fc0899885da504b521068d3b08ca56774cad0bea2b/propcache-0.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:730178f476ef03d3d4d255f0c9fa186cb1d13fd33ffe89d39f2cda4da90ceb71", size = 45927, upload-time = "2025-03-26T03:03:39.394Z" },
- { url = "https://files.pythonhosted.org/packages/ad/6c/d01f9dfbbdc613305e0a831016844987a1fb4861dd221cd4c69b1216b43f/propcache-0.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:967a8eec513dbe08330f10137eacb427b2ca52118769e82ebcfcab0fba92a649", size = 206135, upload-time = "2025-03-26T03:03:40.757Z" },
- { url = "https://files.pythonhosted.org/packages/9a/8a/e6e1c77394088f4cfdace4a91a7328e398ebed745d59c2f6764135c5342d/propcache-0.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b9145c35cc87313b5fd480144f8078716007656093d23059e8993d3a8fa730f", size = 220517, upload-time = "2025-03-26T03:03:42.657Z" },
- { url = "https://files.pythonhosted.org/packages/19/3b/6c44fa59d6418f4239d5db8b1ece757351e85d6f3ca126dfe37d427020c8/propcache-0.3.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e64e948ab41411958670f1093c0a57acfdc3bee5cf5b935671bbd5313bcf229", size = 218952, upload-time = "2025-03-26T03:03:44.549Z" },
- { url = "https://files.pythonhosted.org/packages/7c/e4/4aeb95a1cd085e0558ab0de95abfc5187329616193a1012a6c4c930e9f7a/propcache-0.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:319fa8765bfd6a265e5fa661547556da381e53274bc05094fc9ea50da51bfd46", size = 206593, upload-time = "2025-03-26T03:03:46.114Z" },
- { url = "https://files.pythonhosted.org/packages/da/6a/29fa75de1cbbb302f1e1d684009b969976ca603ee162282ae702287b6621/propcache-0.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c66d8ccbc902ad548312b96ed8d5d266d0d2c6d006fd0f66323e9d8f2dd49be7", size = 196745, upload-time = "2025-03-26T03:03:48.02Z" },
- { url = "https://files.pythonhosted.org/packages/19/7e/2237dad1dbffdd2162de470599fa1a1d55df493b16b71e5d25a0ac1c1543/propcache-0.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2d219b0dbabe75e15e581fc1ae796109b07c8ba7d25b9ae8d650da582bed01b0", size = 203369, upload-time = "2025-03-26T03:03:49.63Z" },
- { url = "https://files.pythonhosted.org/packages/a4/bc/a82c5878eb3afb5c88da86e2cf06e1fe78b7875b26198dbb70fe50a010dc/propcache-0.3.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd6a55f65241c551eb53f8cf4d2f4af33512c39da5d9777694e9d9c60872f519", size = 198723, upload-time = "2025-03-26T03:03:51.091Z" },
- { url = "https://files.pythonhosted.org/packages/17/76/9632254479c55516f51644ddbf747a45f813031af5adcb8db91c0b824375/propcache-0.3.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9979643ffc69b799d50d3a7b72b5164a2e97e117009d7af6dfdd2ab906cb72cd", size = 200751, upload-time = "2025-03-26T03:03:52.631Z" },
- { url = "https://files.pythonhosted.org/packages/3e/c3/a90b773cf639bd01d12a9e20c95be0ae978a5a8abe6d2d343900ae76cd71/propcache-0.3.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4cf9e93a81979f1424f1a3d155213dc928f1069d697e4353edb8a5eba67c6259", size = 210730, upload-time = "2025-03-26T03:03:54.498Z" },
- { url = "https://files.pythonhosted.org/packages/ed/ec/ad5a952cdb9d65c351f88db7c46957edd3d65ffeee72a2f18bd6341433e0/propcache-0.3.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2fce1df66915909ff6c824bbb5eb403d2d15f98f1518e583074671a30fe0c21e", size = 213499, upload-time = "2025-03-26T03:03:56.054Z" },
- { url = "https://files.pythonhosted.org/packages/83/c0/ea5133dda43e298cd2010ec05c2821b391e10980e64ee72c0a76cdbb813a/propcache-0.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4d0dfdd9a2ebc77b869a0b04423591ea8823f791293b527dc1bb896c1d6f1136", size = 207132, upload-time = "2025-03-26T03:03:57.398Z" },
- { url = "https://files.pythonhosted.org/packages/79/dd/71aae9dec59333064cfdd7eb31a63fa09f64181b979802a67a90b2abfcba/propcache-0.3.1-cp310-cp310-win32.whl", hash = "sha256:1f6cc0ad7b4560e5637eb2c994e97b4fa41ba8226069c9277eb5ea7101845b42", size = 40952, upload-time = "2025-03-26T03:03:59.146Z" },
- { url = "https://files.pythonhosted.org/packages/31/0a/49ff7e5056c17dfba62cbdcbb90a29daffd199c52f8e65e5cb09d5f53a57/propcache-0.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:47ef24aa6511e388e9894ec16f0fbf3313a53ee68402bc428744a367ec55b833", size = 45163, upload-time = "2025-03-26T03:04:00.672Z" },
- { url = "https://files.pythonhosted.org/packages/90/0f/5a5319ee83bd651f75311fcb0c492c21322a7fc8f788e4eef23f44243427/propcache-0.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7f30241577d2fef2602113b70ef7231bf4c69a97e04693bde08ddab913ba0ce5", size = 80243, upload-time = "2025-03-26T03:04:01.912Z" },
- { url = "https://files.pythonhosted.org/packages/ce/84/3db5537e0879942783e2256616ff15d870a11d7ac26541336fe1b673c818/propcache-0.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:43593c6772aa12abc3af7784bff4a41ffa921608dd38b77cf1dfd7f5c4e71371", size = 46503, upload-time = "2025-03-26T03:04:03.704Z" },
- { url = "https://files.pythonhosted.org/packages/e2/c8/b649ed972433c3f0d827d7f0cf9ea47162f4ef8f4fe98c5f3641a0bc63ff/propcache-0.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a75801768bbe65499495660b777e018cbe90c7980f07f8aa57d6be79ea6f71da", size = 45934, upload-time = "2025-03-26T03:04:05.257Z" },
- { url = "https://files.pythonhosted.org/packages/59/f9/4c0a5cf6974c2c43b1a6810c40d889769cc8f84cea676cbe1e62766a45f8/propcache-0.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6f1324db48f001c2ca26a25fa25af60711e09b9aaf4b28488602776f4f9a744", size = 233633, upload-time = "2025-03-26T03:04:07.044Z" },
- { url = "https://files.pythonhosted.org/packages/e7/64/66f2f4d1b4f0007c6e9078bd95b609b633d3957fe6dd23eac33ebde4b584/propcache-0.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cdb0f3e1eb6dfc9965d19734d8f9c481b294b5274337a8cb5cb01b462dcb7e0", size = 241124, upload-time = "2025-03-26T03:04:08.676Z" },
- { url = "https://files.pythonhosted.org/packages/aa/bf/7b8c9fd097d511638fa9b6af3d986adbdf567598a567b46338c925144c1b/propcache-0.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1eb34d90aac9bfbced9a58b266f8946cb5935869ff01b164573a7634d39fbcb5", size = 240283, upload-time = "2025-03-26T03:04:10.172Z" },
- { url = "https://files.pythonhosted.org/packages/fa/c9/e85aeeeaae83358e2a1ef32d6ff50a483a5d5248bc38510d030a6f4e2816/propcache-0.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f35c7070eeec2cdaac6fd3fe245226ed2a6292d3ee8c938e5bb645b434c5f256", size = 232498, upload-time = "2025-03-26T03:04:11.616Z" },
- { url = "https://files.pythonhosted.org/packages/8e/66/acb88e1f30ef5536d785c283af2e62931cb934a56a3ecf39105887aa8905/propcache-0.3.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b23c11c2c9e6d4e7300c92e022046ad09b91fd00e36e83c44483df4afa990073", size = 221486, upload-time = "2025-03-26T03:04:13.102Z" },
- { url = "https://files.pythonhosted.org/packages/f5/f9/233ddb05ffdcaee4448508ee1d70aa7deff21bb41469ccdfcc339f871427/propcache-0.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3e19ea4ea0bf46179f8a3652ac1426e6dcbaf577ce4b4f65be581e237340420d", size = 222675, upload-time = "2025-03-26T03:04:14.658Z" },
- { url = "https://files.pythonhosted.org/packages/98/b8/eb977e28138f9e22a5a789daf608d36e05ed93093ef12a12441030da800a/propcache-0.3.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:bd39c92e4c8f6cbf5f08257d6360123af72af9f4da75a690bef50da77362d25f", size = 215727, upload-time = "2025-03-26T03:04:16.207Z" },
- { url = "https://files.pythonhosted.org/packages/89/2d/5f52d9c579f67b8ee1edd9ec073c91b23cc5b7ff7951a1e449e04ed8fdf3/propcache-0.3.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b0313e8b923b3814d1c4a524c93dfecea5f39fa95601f6a9b1ac96cd66f89ea0", size = 217878, upload-time = "2025-03-26T03:04:18.11Z" },
- { url = "https://files.pythonhosted.org/packages/7a/fd/5283e5ed8a82b00c7a989b99bb6ea173db1ad750bf0bf8dff08d3f4a4e28/propcache-0.3.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e861ad82892408487be144906a368ddbe2dc6297074ade2d892341b35c59844a", size = 230558, upload-time = "2025-03-26T03:04:19.562Z" },
- { url = "https://files.pythonhosted.org/packages/90/38/ab17d75938ef7ac87332c588857422ae126b1c76253f0f5b1242032923ca/propcache-0.3.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:61014615c1274df8da5991a1e5da85a3ccb00c2d4701ac6f3383afd3ca47ab0a", size = 233754, upload-time = "2025-03-26T03:04:21.065Z" },
- { url = "https://files.pythonhosted.org/packages/06/5d/3b921b9c60659ae464137508d3b4c2b3f52f592ceb1964aa2533b32fcf0b/propcache-0.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:71ebe3fe42656a2328ab08933d420df5f3ab121772eef78f2dc63624157f0ed9", size = 226088, upload-time = "2025-03-26T03:04:22.718Z" },
- { url = "https://files.pythonhosted.org/packages/54/6e/30a11f4417d9266b5a464ac5a8c5164ddc9dd153dfa77bf57918165eb4ae/propcache-0.3.1-cp311-cp311-win32.whl", hash = "sha256:58aa11f4ca8b60113d4b8e32d37e7e78bd8af4d1a5b5cb4979ed856a45e62005", size = 40859, upload-time = "2025-03-26T03:04:24.039Z" },
- { url = "https://files.pythonhosted.org/packages/1d/3a/8a68dd867da9ca2ee9dfd361093e9cb08cb0f37e5ddb2276f1b5177d7731/propcache-0.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:9532ea0b26a401264b1365146c440a6d78269ed41f83f23818d4b79497aeabe7", size = 45153, upload-time = "2025-03-26T03:04:25.211Z" },
- { url = "https://files.pythonhosted.org/packages/41/aa/ca78d9be314d1e15ff517b992bebbed3bdfef5b8919e85bf4940e57b6137/propcache-0.3.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f78eb8422acc93d7b69964012ad7048764bb45a54ba7a39bb9e146c72ea29723", size = 80430, upload-time = "2025-03-26T03:04:26.436Z" },
- { url = "https://files.pythonhosted.org/packages/1a/d8/f0c17c44d1cda0ad1979af2e593ea290defdde9eaeb89b08abbe02a5e8e1/propcache-0.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:89498dd49c2f9a026ee057965cdf8192e5ae070ce7d7a7bd4b66a8e257d0c976", size = 46637, upload-time = "2025-03-26T03:04:27.932Z" },
- { url = "https://files.pythonhosted.org/packages/ae/bd/c1e37265910752e6e5e8a4c1605d0129e5b7933c3dc3cf1b9b48ed83b364/propcache-0.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:09400e98545c998d57d10035ff623266927cb784d13dd2b31fd33b8a5316b85b", size = 46123, upload-time = "2025-03-26T03:04:30.659Z" },
- { url = "https://files.pythonhosted.org/packages/d4/b0/911eda0865f90c0c7e9f0415d40a5bf681204da5fd7ca089361a64c16b28/propcache-0.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa8efd8c5adc5a2c9d3b952815ff8f7710cefdcaf5f2c36d26aff51aeca2f12f", size = 243031, upload-time = "2025-03-26T03:04:31.977Z" },
- { url = "https://files.pythonhosted.org/packages/0a/06/0da53397c76a74271621807265b6eb61fb011451b1ddebf43213df763669/propcache-0.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2fe5c910f6007e716a06d269608d307b4f36e7babee5f36533722660e8c4a70", size = 249100, upload-time = "2025-03-26T03:04:33.45Z" },
- { url = "https://files.pythonhosted.org/packages/f1/eb/13090e05bf6b963fc1653cdc922133ced467cb4b8dab53158db5a37aa21e/propcache-0.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0ab8cf8cdd2194f8ff979a43ab43049b1df0b37aa64ab7eca04ac14429baeb7", size = 250170, upload-time = "2025-03-26T03:04:35.542Z" },
- { url = "https://files.pythonhosted.org/packages/3b/4c/f72c9e1022b3b043ec7dc475a0f405d4c3e10b9b1d378a7330fecf0652da/propcache-0.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:563f9d8c03ad645597b8d010ef4e9eab359faeb11a0a2ac9f7b4bc8c28ebef25", size = 245000, upload-time = "2025-03-26T03:04:37.501Z" },
- { url = "https://files.pythonhosted.org/packages/e8/fd/970ca0e22acc829f1adf5de3724085e778c1ad8a75bec010049502cb3a86/propcache-0.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb6e0faf8cb6b4beea5d6ed7b5a578254c6d7df54c36ccd3d8b3eb00d6770277", size = 230262, upload-time = "2025-03-26T03:04:39.532Z" },
- { url = "https://files.pythonhosted.org/packages/c4/42/817289120c6b9194a44f6c3e6b2c3277c5b70bbad39e7df648f177cc3634/propcache-0.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1c5c7ab7f2bb3f573d1cb921993006ba2d39e8621019dffb1c5bc94cdbae81e8", size = 236772, upload-time = "2025-03-26T03:04:41.109Z" },
- { url = "https://files.pythonhosted.org/packages/7c/9c/3b3942b302badd589ad6b672da3ca7b660a6c2f505cafd058133ddc73918/propcache-0.3.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:050b571b2e96ec942898f8eb46ea4bfbb19bd5502424747e83badc2d4a99a44e", size = 231133, upload-time = "2025-03-26T03:04:42.544Z" },
- { url = "https://files.pythonhosted.org/packages/98/a1/75f6355f9ad039108ff000dfc2e19962c8dea0430da9a1428e7975cf24b2/propcache-0.3.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e1c4d24b804b3a87e9350f79e2371a705a188d292fd310e663483af6ee6718ee", size = 230741, upload-time = "2025-03-26T03:04:44.06Z" },
- { url = "https://files.pythonhosted.org/packages/67/0c/3e82563af77d1f8731132166da69fdfd95e71210e31f18edce08a1eb11ea/propcache-0.3.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e4fe2a6d5ce975c117a6bb1e8ccda772d1e7029c1cca1acd209f91d30fa72815", size = 244047, upload-time = "2025-03-26T03:04:45.983Z" },
- { url = "https://files.pythonhosted.org/packages/f7/50/9fb7cca01532a08c4d5186d7bb2da6c4c587825c0ae134b89b47c7d62628/propcache-0.3.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:feccd282de1f6322f56f6845bf1207a537227812f0a9bf5571df52bb418d79d5", size = 246467, upload-time = "2025-03-26T03:04:47.699Z" },
- { url = "https://files.pythonhosted.org/packages/a9/02/ccbcf3e1c604c16cc525309161d57412c23cf2351523aedbb280eb7c9094/propcache-0.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ec314cde7314d2dd0510c6787326bbffcbdc317ecee6b7401ce218b3099075a7", size = 241022, upload-time = "2025-03-26T03:04:49.195Z" },
- { url = "https://files.pythonhosted.org/packages/db/19/e777227545e09ca1e77a6e21274ae9ec45de0f589f0ce3eca2a41f366220/propcache-0.3.1-cp312-cp312-win32.whl", hash = "sha256:7d2d5a0028d920738372630870e7d9644ce437142197f8c827194fca404bf03b", size = 40647, upload-time = "2025-03-26T03:04:50.595Z" },
- { url = "https://files.pythonhosted.org/packages/24/bb/3b1b01da5dd04c77a204c84e538ff11f624e31431cfde7201d9110b092b1/propcache-0.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:88c423efef9d7a59dae0614eaed718449c09a5ac79a5f224a8b9664d603f04a3", size = 44784, upload-time = "2025-03-26T03:04:51.791Z" },
- { url = "https://files.pythonhosted.org/packages/58/60/f645cc8b570f99be3cf46714170c2de4b4c9d6b827b912811eff1eb8a412/propcache-0.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f1528ec4374617a7a753f90f20e2f551121bb558fcb35926f99e3c42367164b8", size = 77865, upload-time = "2025-03-26T03:04:53.406Z" },
- { url = "https://files.pythonhosted.org/packages/6f/d4/c1adbf3901537582e65cf90fd9c26fde1298fde5a2c593f987112c0d0798/propcache-0.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc1915ec523b3b494933b5424980831b636fe483d7d543f7afb7b3bf00f0c10f", size = 45452, upload-time = "2025-03-26T03:04:54.624Z" },
- { url = "https://files.pythonhosted.org/packages/d1/b5/fe752b2e63f49f727c6c1c224175d21b7d1727ce1d4873ef1c24c9216830/propcache-0.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a110205022d077da24e60b3df8bcee73971be9575dec5573dd17ae5d81751111", size = 44800, upload-time = "2025-03-26T03:04:55.844Z" },
- { url = "https://files.pythonhosted.org/packages/62/37/fc357e345bc1971e21f76597028b059c3d795c5ca7690d7a8d9a03c9708a/propcache-0.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d249609e547c04d190e820d0d4c8ca03ed4582bcf8e4e160a6969ddfb57b62e5", size = 225804, upload-time = "2025-03-26T03:04:57.158Z" },
- { url = "https://files.pythonhosted.org/packages/0d/f1/16e12c33e3dbe7f8b737809bad05719cff1dccb8df4dafbcff5575002c0e/propcache-0.3.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ced33d827625d0a589e831126ccb4f5c29dfdf6766cac441d23995a65825dcb", size = 230650, upload-time = "2025-03-26T03:04:58.61Z" },
- { url = "https://files.pythonhosted.org/packages/3e/a2/018b9f2ed876bf5091e60153f727e8f9073d97573f790ff7cdf6bc1d1fb8/propcache-0.3.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4114c4ada8f3181af20808bedb250da6bae56660e4b8dfd9cd95d4549c0962f7", size = 234235, upload-time = "2025-03-26T03:05:00.599Z" },
- { url = "https://files.pythonhosted.org/packages/45/5f/3faee66fc930dfb5da509e34c6ac7128870631c0e3582987fad161fcb4b1/propcache-0.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:975af16f406ce48f1333ec5e912fe11064605d5c5b3f6746969077cc3adeb120", size = 228249, upload-time = "2025-03-26T03:05:02.11Z" },
- { url = "https://files.pythonhosted.org/packages/62/1e/a0d5ebda5da7ff34d2f5259a3e171a94be83c41eb1e7cd21a2105a84a02e/propcache-0.3.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a34aa3a1abc50740be6ac0ab9d594e274f59960d3ad253cd318af76b996dd654", size = 214964, upload-time = "2025-03-26T03:05:03.599Z" },
- { url = "https://files.pythonhosted.org/packages/db/a0/d72da3f61ceab126e9be1f3bc7844b4e98c6e61c985097474668e7e52152/propcache-0.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9cec3239c85ed15bfaded997773fdad9fb5662b0a7cbc854a43f291eb183179e", size = 222501, upload-time = "2025-03-26T03:05:05.107Z" },
- { url = "https://files.pythonhosted.org/packages/18/6d/a008e07ad7b905011253adbbd97e5b5375c33f0b961355ca0a30377504ac/propcache-0.3.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:05543250deac8e61084234d5fc54f8ebd254e8f2b39a16b1dce48904f45b744b", size = 217917, upload-time = "2025-03-26T03:05:06.59Z" },
- { url = "https://files.pythonhosted.org/packages/98/37/02c9343ffe59e590e0e56dc5c97d0da2b8b19fa747ebacf158310f97a79a/propcache-0.3.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5cb5918253912e088edbf023788de539219718d3b10aef334476b62d2b53de53", size = 217089, upload-time = "2025-03-26T03:05:08.1Z" },
- { url = "https://files.pythonhosted.org/packages/53/1b/d3406629a2c8a5666d4674c50f757a77be119b113eedd47b0375afdf1b42/propcache-0.3.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f3bbecd2f34d0e6d3c543fdb3b15d6b60dd69970c2b4c822379e5ec8f6f621d5", size = 228102, upload-time = "2025-03-26T03:05:09.982Z" },
- { url = "https://files.pythonhosted.org/packages/cd/a7/3664756cf50ce739e5f3abd48febc0be1a713b1f389a502ca819791a6b69/propcache-0.3.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aca63103895c7d960a5b9b044a83f544b233c95e0dcff114389d64d762017af7", size = 230122, upload-time = "2025-03-26T03:05:11.408Z" },
- { url = "https://files.pythonhosted.org/packages/35/36/0bbabaacdcc26dac4f8139625e930f4311864251276033a52fd52ff2a274/propcache-0.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a0a9898fdb99bf11786265468571e628ba60af80dc3f6eb89a3545540c6b0ef", size = 226818, upload-time = "2025-03-26T03:05:12.909Z" },
- { url = "https://files.pythonhosted.org/packages/cc/27/4e0ef21084b53bd35d4dae1634b6d0bad35e9c58ed4f032511acca9d4d26/propcache-0.3.1-cp313-cp313-win32.whl", hash = "sha256:3a02a28095b5e63128bcae98eb59025924f121f048a62393db682f049bf4ac24", size = 40112, upload-time = "2025-03-26T03:05:14.289Z" },
- { url = "https://files.pythonhosted.org/packages/a6/2c/a54614d61895ba6dd7ac8f107e2b2a0347259ab29cbf2ecc7b94fa38c4dc/propcache-0.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:813fbb8b6aea2fc9659815e585e548fe706d6f663fa73dff59a1677d4595a037", size = 44034, upload-time = "2025-03-26T03:05:15.616Z" },
- { url = "https://files.pythonhosted.org/packages/5a/a8/0a4fd2f664fc6acc66438370905124ce62e84e2e860f2557015ee4a61c7e/propcache-0.3.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a444192f20f5ce8a5e52761a031b90f5ea6288b1eef42ad4c7e64fef33540b8f", size = 82613, upload-time = "2025-03-26T03:05:16.913Z" },
- { url = "https://files.pythonhosted.org/packages/4d/e5/5ef30eb2cd81576256d7b6caaa0ce33cd1d2c2c92c8903cccb1af1a4ff2f/propcache-0.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fbe94666e62ebe36cd652f5fc012abfbc2342de99b523f8267a678e4dfdee3c", size = 47763, upload-time = "2025-03-26T03:05:18.607Z" },
- { url = "https://files.pythonhosted.org/packages/87/9a/87091ceb048efeba4d28e903c0b15bcc84b7c0bf27dc0261e62335d9b7b8/propcache-0.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f011f104db880f4e2166bcdcf7f58250f7a465bc6b068dc84c824a3d4a5c94dc", size = 47175, upload-time = "2025-03-26T03:05:19.85Z" },
- { url = "https://files.pythonhosted.org/packages/3e/2f/854e653c96ad1161f96194c6678a41bbb38c7947d17768e8811a77635a08/propcache-0.3.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e584b6d388aeb0001d6d5c2bd86b26304adde6d9bb9bfa9c4889805021b96de", size = 292265, upload-time = "2025-03-26T03:05:21.654Z" },
- { url = "https://files.pythonhosted.org/packages/40/8d/090955e13ed06bc3496ba4a9fb26c62e209ac41973cb0d6222de20c6868f/propcache-0.3.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a17583515a04358b034e241f952f1715243482fc2c2945fd99a1b03a0bd77d6", size = 294412, upload-time = "2025-03-26T03:05:23.147Z" },
- { url = "https://files.pythonhosted.org/packages/39/e6/d51601342e53cc7582449e6a3c14a0479fab2f0750c1f4d22302e34219c6/propcache-0.3.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5aed8d8308215089c0734a2af4f2e95eeb360660184ad3912686c181e500b2e7", size = 294290, upload-time = "2025-03-26T03:05:24.577Z" },
- { url = "https://files.pythonhosted.org/packages/3b/4d/be5f1a90abc1881884aa5878989a1acdafd379a91d9c7e5e12cef37ec0d7/propcache-0.3.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d8e309ff9a0503ef70dc9a0ebd3e69cf7b3894c9ae2ae81fc10943c37762458", size = 282926, upload-time = "2025-03-26T03:05:26.459Z" },
- { url = "https://files.pythonhosted.org/packages/57/2b/8f61b998c7ea93a2b7eca79e53f3e903db1787fca9373af9e2cf8dc22f9d/propcache-0.3.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b655032b202028a582d27aeedc2e813299f82cb232f969f87a4fde491a233f11", size = 267808, upload-time = "2025-03-26T03:05:28.188Z" },
- { url = "https://files.pythonhosted.org/packages/11/1c/311326c3dfce59c58a6098388ba984b0e5fb0381ef2279ec458ef99bd547/propcache-0.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f64d91b751df77931336b5ff7bafbe8845c5770b06630e27acd5dbb71e1931c", size = 290916, upload-time = "2025-03-26T03:05:29.757Z" },
- { url = "https://files.pythonhosted.org/packages/4b/74/91939924b0385e54dc48eb2e4edd1e4903ffd053cf1916ebc5347ac227f7/propcache-0.3.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:19a06db789a4bd896ee91ebc50d059e23b3639c25d58eb35be3ca1cbe967c3bf", size = 262661, upload-time = "2025-03-26T03:05:31.472Z" },
- { url = "https://files.pythonhosted.org/packages/c2/d7/e6079af45136ad325c5337f5dd9ef97ab5dc349e0ff362fe5c5db95e2454/propcache-0.3.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:bef100c88d8692864651b5f98e871fb090bd65c8a41a1cb0ff2322db39c96c27", size = 264384, upload-time = "2025-03-26T03:05:32.984Z" },
- { url = "https://files.pythonhosted.org/packages/b7/d5/ba91702207ac61ae6f1c2da81c5d0d6bf6ce89e08a2b4d44e411c0bbe867/propcache-0.3.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:87380fb1f3089d2a0b8b00f006ed12bd41bd858fabfa7330c954c70f50ed8757", size = 291420, upload-time = "2025-03-26T03:05:34.496Z" },
- { url = "https://files.pythonhosted.org/packages/58/70/2117780ed7edcd7ba6b8134cb7802aada90b894a9810ec56b7bb6018bee7/propcache-0.3.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e474fc718e73ba5ec5180358aa07f6aded0ff5f2abe700e3115c37d75c947e18", size = 290880, upload-time = "2025-03-26T03:05:36.256Z" },
- { url = "https://files.pythonhosted.org/packages/4a/1f/ecd9ce27710021ae623631c0146719280a929d895a095f6d85efb6a0be2e/propcache-0.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:17d1c688a443355234f3c031349da69444be052613483f3e4158eef751abcd8a", size = 287407, upload-time = "2025-03-26T03:05:37.799Z" },
- { url = "https://files.pythonhosted.org/packages/3e/66/2e90547d6b60180fb29e23dc87bd8c116517d4255240ec6d3f7dc23d1926/propcache-0.3.1-cp313-cp313t-win32.whl", hash = "sha256:359e81a949a7619802eb601d66d37072b79b79c2505e6d3fd8b945538411400d", size = 42573, upload-time = "2025-03-26T03:05:39.193Z" },
- { url = "https://files.pythonhosted.org/packages/cb/8f/50ad8599399d1861b4d2b6b45271f0ef6af1b09b0a2386a46dbaf19c9535/propcache-0.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e7fb9a84c9abbf2b2683fa3e7b0d7da4d8ecf139a1c635732a8bda29c5214b0e", size = 46757, upload-time = "2025-03-26T03:05:40.811Z" },
- { url = "https://files.pythonhosted.org/packages/aa/e1/4a782cdc7ebc42dfb44224dabf93b481395a0b6cbc9f0149785edbbab19c/propcache-0.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ed5f6d2edbf349bd8d630e81f474d33d6ae5d07760c44d33cd808e2f5c8f4ae6", size = 81368, upload-time = "2025-03-26T03:05:42.15Z" },
- { url = "https://files.pythonhosted.org/packages/18/c6/9a39b2646a71321815d8d616e890851af9fb327af7d1b9fdce7d2d8377ca/propcache-0.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:668ddddc9f3075af019f784456267eb504cb77c2c4bd46cc8402d723b4d200bf", size = 47037, upload-time = "2025-03-26T03:05:44.279Z" },
- { url = "https://files.pythonhosted.org/packages/f3/e2/88ad1c4c42861dd09b45924e468c42a1beb2c5267cb960b7a9f6af67dd04/propcache-0.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0c86e7ceea56376216eba345aa1fc6a8a6b27ac236181f840d1d7e6a1ea9ba5c", size = 46462, upload-time = "2025-03-26T03:05:45.569Z" },
- { url = "https://files.pythonhosted.org/packages/ae/7e/3e3b36854e96be2e881bc6e87293d59c74dd734dd038dd4981474be44e26/propcache-0.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83be47aa4e35b87c106fc0c84c0fc069d3f9b9b06d3c494cd404ec6747544894", size = 209214, upload-time = "2025-03-26T03:05:47.366Z" },
- { url = "https://files.pythonhosted.org/packages/11/1a/ac0f757cc0babdc8217056fca85150066cf43bf11db9651e6b7d8e0646d6/propcache-0.3.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:27c6ac6aa9fc7bc662f594ef380707494cb42c22786a558d95fcdedb9aa5d035", size = 224702, upload-time = "2025-03-26T03:05:48.946Z" },
- { url = "https://files.pythonhosted.org/packages/92/0a/0cf77d0e984b7058019ffa5385b3efd6962cbd5340a8f278ae103032863a/propcache-0.3.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a956dff37080b352c1c40b2966b09defb014347043e740d420ca1eb7c9b908", size = 223085, upload-time = "2025-03-26T03:05:50.472Z" },
- { url = "https://files.pythonhosted.org/packages/05/fc/cb52a0caf803caff9b95b0a99e7c9c87f15b7e34ba0feebfd2572b49013d/propcache-0.3.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82de5da8c8893056603ac2d6a89eb8b4df49abf1a7c19d536984c8dd63f481d5", size = 209613, upload-time = "2025-03-26T03:05:52.36Z" },
- { url = "https://files.pythonhosted.org/packages/e5/fc/b1d1fdffbe1e0278ab535f8d21fc6b030889417714a545755bdd5ebe9bb0/propcache-0.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c3c3a203c375b08fd06a20da3cf7aac293b834b6f4f4db71190e8422750cca5", size = 199931, upload-time = "2025-03-26T03:05:54.302Z" },
- { url = "https://files.pythonhosted.org/packages/23/a9/2a2f8d93d8f526c35dd8dbbc4a1ac22a106712cd821e15e2a6530aea8931/propcache-0.3.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b303b194c2e6f171cfddf8b8ba30baefccf03d36a4d9cab7fd0bb68ba476a3d7", size = 208937, upload-time = "2025-03-26T03:05:56.38Z" },
- { url = "https://files.pythonhosted.org/packages/ef/71/5247a264b95e8d4ba86757cf9ad6a523d764bd4579a2d80007a2d4d2b0ad/propcache-0.3.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:916cd229b0150129d645ec51614d38129ee74c03293a9f3f17537be0029a9641", size = 202577, upload-time = "2025-03-26T03:05:58.325Z" },
- { url = "https://files.pythonhosted.org/packages/6f/4e/c8ec771731f1b1e7d07bd8875f1d13c1564b5d60f7483624d021eaef5687/propcache-0.3.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a461959ead5b38e2581998700b26346b78cd98540b5524796c175722f18b0294", size = 204669, upload-time = "2025-03-26T03:05:59.849Z" },
- { url = "https://files.pythonhosted.org/packages/c5/b8/bdfcb1170a7b8504226064d7c0b4deb61acbcc6bb2e754ee25fb36c1b72a/propcache-0.3.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:069e7212890b0bcf9b2be0a03afb0c2d5161d91e1bf51569a64f629acc7defbf", size = 214334, upload-time = "2025-03-26T03:06:01.905Z" },
- { url = "https://files.pythonhosted.org/packages/72/c6/fdb9e8ba161a4e12c75a7415cb99314cad195d3b8ae9d770783cec54001e/propcache-0.3.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ef2e4e91fb3945769e14ce82ed53007195e616a63aa43b40fb7ebaaf907c8d4c", size = 218052, upload-time = "2025-03-26T03:06:03.586Z" },
- { url = "https://files.pythonhosted.org/packages/67/3f/0dd87220f61598b61b590a8b3562142ae475a9c0f694ee32bf97e4e41d44/propcache-0.3.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8638f99dca15b9dff328fb6273e09f03d1c50d9b6512f3b65a4154588a7595fe", size = 210852, upload-time = "2025-03-26T03:06:05.045Z" },
- { url = "https://files.pythonhosted.org/packages/7b/4e/e332164372af66992c07b470448beb7e36ce7dba6a06c6c2b6131f112e74/propcache-0.3.1-cp39-cp39-win32.whl", hash = "sha256:6f173bbfe976105aaa890b712d1759de339d8a7cef2fc0a1714cc1a1e1c47f64", size = 41481, upload-time = "2025-03-26T03:06:07.507Z" },
- { url = "https://files.pythonhosted.org/packages/61/73/d64abb7bb5d18880ecfac152247c0f1a5807256ea21e4737ce3019afffeb/propcache-0.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:603f1fe4144420374f1a69b907494c3acbc867a581c2d49d4175b0de7cc64566", size = 45720, upload-time = "2025-03-26T03:06:09.139Z" },
- { url = "https://files.pythonhosted.org/packages/b8/d3/c3cb8f1d6ae3b37f83e1de806713a9b3642c5895f0215a62e1a4bd6e5e34/propcache-0.3.1-py3-none-any.whl", hash = "sha256:9a8ecf38de50a7f518c21568c80f985e776397b902f1ce0b01f799aba1608b40", size = 12376, upload-time = "2025-03-26T03:06:10.5Z" },
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534, upload-time = "2025-10-08T19:46:02.083Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526, upload-time = "2025-10-08T19:46:03.884Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263, upload-time = "2025-10-08T19:46:05.405Z" },
+ { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012, upload-time = "2025-10-08T19:46:07.165Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491, upload-time = "2025-10-08T19:46:08.909Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319, upload-time = "2025-10-08T19:46:10.7Z" },
+ { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856, upload-time = "2025-10-08T19:46:12.003Z" },
+ { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241, upload-time = "2025-10-08T19:46:13.495Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552, upload-time = "2025-10-08T19:46:14.938Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113, upload-time = "2025-10-08T19:46:16.695Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778, upload-time = "2025-10-08T19:46:18.023Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047, upload-time = "2025-10-08T19:46:19.449Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093, upload-time = "2025-10-08T19:46:20.643Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638, upload-time = "2025-10-08T19:46:21.935Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229, upload-time = "2025-10-08T19:46:23.368Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" },
+ { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" },
+ { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" },
+ { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" },
+ { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" },
+ { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" },
+ { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" },
+ { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" },
+ { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" },
+ { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" },
+ { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" },
+ { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" },
+ { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" },
+ { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" },
+ { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" },
+ { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" },
+ { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" },
+ { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" },
+ { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" },
+ { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" },
+ { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" },
+ { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" },
+ { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" },
+ { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" },
+ { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" },
+ { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" },
+ { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" },
+ { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" },
+ { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" },
+ { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" },
+ { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" },
+ { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" },
+ { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" },
+ { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" },
+ { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" },
+ { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" },
+ { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" },
+ { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" },
+ { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/01/0ebaec9003f5d619a7475165961f8e3083cf8644d704b60395df3601632d/propcache-0.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff", size = 80277, upload-time = "2025-10-08T19:48:36.647Z" },
+ { url = "https://files.pythonhosted.org/packages/34/58/04af97ac586b4ef6b9026c3fd36ee7798b737a832f5d3440a4280dcebd3a/propcache-0.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb", size = 45865, upload-time = "2025-10-08T19:48:37.859Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/19/b65d98ae21384518b291d9939e24a8aeac4fdb5101b732576f8f7540e834/propcache-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac", size = 47636, upload-time = "2025-10-08T19:48:39.038Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/0f/317048c6d91c356c7154dca5af019e6effeb7ee15fa6a6db327cc19e12b4/propcache-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888", size = 201126, upload-time = "2025-10-08T19:48:40.774Z" },
+ { url = "https://files.pythonhosted.org/packages/71/69/0b2a7a5a6ee83292b4b997dbd80549d8ce7d40b6397c1646c0d9495f5a85/propcache-0.4.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc", size = 209837, upload-time = "2025-10-08T19:48:42.167Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/92/c699ac495a6698df6e497fc2de27af4b6ace10d8e76528357ce153722e45/propcache-0.4.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a", size = 215578, upload-time = "2025-10-08T19:48:43.56Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/ee/14de81c5eb02c0ee4f500b4e39c4e1bd0677c06e72379e6ab18923c773fc/propcache-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88", size = 197187, upload-time = "2025-10-08T19:48:45.309Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/94/48dce9aaa6d8dd5a0859bad75158ec522546d4ac23f8e2f05fac469477dd/propcache-0.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00", size = 193478, upload-time = "2025-10-08T19:48:47.743Z" },
+ { url = "https://files.pythonhosted.org/packages/60/b5/0516b563e801e1ace212afde869a0596a0d7115eec0b12d296d75633fb29/propcache-0.4.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0", size = 190650, upload-time = "2025-10-08T19:48:49.373Z" },
+ { url = "https://files.pythonhosted.org/packages/24/89/e0f7d4a5978cd56f8cd67735f74052f257dc471ec901694e430f0d1572fe/propcache-0.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e", size = 200251, upload-time = "2025-10-08T19:48:51.4Z" },
+ { url = "https://files.pythonhosted.org/packages/06/7d/a1fac863d473876ed4406c914f2e14aa82d2f10dd207c9e16fc383cc5a24/propcache-0.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781", size = 200919, upload-time = "2025-10-08T19:48:53.227Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/4e/f86a256ff24944cf5743e4e6c6994e3526f6acfcfb55e21694c2424f758c/propcache-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183", size = 193211, upload-time = "2025-10-08T19:48:55.027Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/3f/3fbad5f4356b068f1b047d300a6ff2c66614d7030f078cd50be3fec04228/propcache-0.4.1-cp39-cp39-win32.whl", hash = "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19", size = 38314, upload-time = "2025-10-08T19:48:56.792Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/45/d78d136c3a3d215677abb886785aae744da2c3005bcb99e58640c56529b1/propcache-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f", size = 41912, upload-time = "2025-10-08T19:48:57.995Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/2a/b0632941f25139f4e58450b307242951f7c2717a5704977c6d5323a800af/propcache-0.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938", size = 38450, upload-time = "2025-10-08T19:48:59.349Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
]
[[package]]
@@ -1082,97 +1654,161 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/ec/1fb891d8a2660716aadb2143235481d15ed1cbfe3ad669194690b0604492/pycountry-24.6.1-py3-none-any.whl", hash = "sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f", size = 6335189, upload-time = "2024-06-01T04:11:49.711Z" },
]
+[[package]]
+name = "pycparser"
+version = "2.23"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
+]
+
[[package]]
name = "pygments"
-version = "2.19.1"
+version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+]
+
+[[package]]
+name = "pyjwt"
+version = "2.10.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
+]
+
+[package.optional-dependencies]
+crypto = [
+ { name = "cryptography" },
]
[[package]]
name = "pymongo"
-version = "4.13.0"
+version = "4.15.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "dnspython", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "dnspython", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/7b/a709c85dc716eb85b69f71a4bb375cf1e72758a7e872103f27551243319c/pymongo-4.15.3.tar.gz", hash = "sha256:7a981271347623b5319932796690c2d301668ac3a1965974ac9f5c3b8a22cea5", size = 2470801, upload-time = "2025-10-07T21:57:50.384Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/38/7ba7e7b57ccf2b04b63796c097c35b32339b2cb6e4d851d9dbb84426dc99/pymongo-4.15.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:482ca9b775747562ce1589df10c97a0e62a604ce5addf933e5819dd967c5e23c", size = 811331, upload-time = "2025-10-07T21:55:59.15Z" },
+ { url = "https://files.pythonhosted.org/packages/11/36/4bd2aa400a64935b59d68d1c35c168bf61613f1f2bb824757079b2415cda/pymongo-4.15.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7eb497519f42ac89c30919a51f80e68a070cfc2f3b0543cac74833cd45a6b9c", size = 811673, upload-time = "2025-10-07T21:56:00.712Z" },
+ { url = "https://files.pythonhosted.org/packages/37/fb/03c3bd14e6eb5236b360cff8598677c4b7b9557eed3021d9b3f6e82de51d/pymongo-4.15.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4a0a054e9937ec8fdb465835509b176f6b032851c8648f6a5d1b19932d0eacd6", size = 1185479, upload-time = "2025-10-07T21:56:02.297Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/27/b5f21d9a556e31d083bb17d0c026244a604a96f7bdb277fd48dee99415ee/pymongo-4.15.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49fd6e158cf75771b2685a8a221a40ab96010ae34dd116abd06371dc6c38ab60", size = 1203867, upload-time = "2025-10-07T21:56:03.621Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/09/ffe1a114d7a39f6746c27a6f5a717b1dc5ea763cb0458a9a679142f623aa/pymongo-4.15.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82a490f1ade4ec6a72068e3676b04c126e3043e69b38ec474a87c6444cf79098", size = 1242537, upload-time = "2025-10-07T21:56:04.973Z" },
+ { url = "https://files.pythonhosted.org/packages/af/60/b7968e855284bb67d366dfb50b6a9df4f69676fbbae51f3e647d2dcb12eb/pymongo-4.15.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:982107c667921e896292f4be09c057e2f1a40c645c9bfc724af5dd5fb8398094", size = 1232832, upload-time = "2025-10-07T21:56:06.287Z" },
+ { url = "https://files.pythonhosted.org/packages/23/47/763945c63690d5c1a54d1d2ace352ba150b9e49a5cfdf44fb237e092e604/pymongo-4.15.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:45aebbd369ca79b7c46eaea5b04d2e4afca4eda117b68965a07a9da05d774e4d", size = 1200177, upload-time = "2025-10-07T21:56:07.671Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/c2/1ace9cf4b88addceb5077e5490238a9e20dc9fef75ae4de146f57f408a06/pymongo-4.15.3-cp310-cp310-win32.whl", hash = "sha256:90ad56bd1d769d2f44af74f0fd0c276512361644a3c636350447994412cbc9a1", size = 798320, upload-time = "2025-10-07T21:56:09.917Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/b7/86563ec80fc41f644c813a3625d8b5672fd1d2b52da53727eca766dfc162/pymongo-4.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:8bd6dd736f5d07a825caf52c38916d5452edc0fac7aee43ec67aba6f61c2dbb7", size = 808150, upload-time = "2025-10-07T21:56:11.562Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/b3/f136483c3d13224ad0b80ac2b7c8f7adb735a296b5e8c94cfc2415b77d70/pymongo-4.15.3-cp310-cp310-win_arm64.whl", hash = "sha256:300eaf83ad053e51966be1839324341b08eaf880d3dc63ada7942d5912e09c49", size = 800930, upload-time = "2025-10-07T21:56:12.917Z" },
+ { url = "https://files.pythonhosted.org/packages/73/04/3dbc426c5868961d8308f19750243f8472f587f5f8a5029ce6953ba74b82/pymongo-4.15.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a13d8f7141294404ce46dfbabb2f2d17e9b1192456651ae831fa351f86fbeb", size = 865889, upload-time = "2025-10-07T21:56:14.165Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/39/7f7652f53dd0eb0c4c3420a175183da757e9c53f9a2bf3ebc589758a1b9e/pymongo-4.15.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:17d13458baf4a6a9f2e787d95adf8ec50d412accb9926a044bd1c41029c323b2", size = 866230, upload-time = "2025-10-07T21:56:15.587Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/0b/84e119e6bab7b19cf4fa1ebb9b4c29bf6c0e76521ed8221b44e3f94a3a37/pymongo-4.15.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fe4bcb8acfb288e238190397d4a699aeb4adb70e8545a6f4e44f99d4e8096ab1", size = 1429788, upload-time = "2025-10-07T21:56:17.362Z" },
+ { url = "https://files.pythonhosted.org/packages/30/39/9905fcb99903de6ac8483114d1c85efe56bc5df735857bdfcc372cf8a3ec/pymongo-4.15.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d09d895c7f08bcbed4d2e96a00e52e9e545ae5a37b32d2dc10099b205a21fc6d", size = 1456758, upload-time = "2025-10-07T21:56:18.841Z" },
+ { url = "https://files.pythonhosted.org/packages/08/58/3c3ac32b8d6ebb654083d53f58e4621cd4c7f306b3b85acef667b80acf08/pymongo-4.15.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:21c0a95a4db72562fd0805e2f76496bf432ba2e27a5651f4b9c670466260c258", size = 1514666, upload-time = "2025-10-07T21:56:20.488Z" },
+ { url = "https://files.pythonhosted.org/packages/19/e2/52f41de224218dc787b7e1187a1ca1a51946dcb979ee553ec917745ccd8d/pymongo-4.15.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89e45d7fa987f4e246cdf43ff001e3f911f73eb19ba9dabc2a6d80df5c97883b", size = 1500703, upload-time = "2025-10-07T21:56:21.874Z" },
+ { url = "https://files.pythonhosted.org/packages/34/0d/a5271073339ba6fc8a5f4e3a62baaa5dd8bf35246c37b512317e2a22848e/pymongo-4.15.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1246a82fa6dd73ac2c63aa7e463752d5d1ca91e0c7a23396b78f21273befd3a7", size = 1452013, upload-time = "2025-10-07T21:56:23.526Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/3b/f39b721ca0db9f0820e12eeffec84eb87b7502abb13a685226c5434f9618/pymongo-4.15.3-cp311-cp311-win32.whl", hash = "sha256:9483521c03f6017336f54445652ead3145154e8d3ea06418e52cea57fee43292", size = 844461, upload-time = "2025-10-07T21:56:24.867Z" },
+ { url = "https://files.pythonhosted.org/packages/12/72/e58b9df862edbf238a1d71fa32749a6eaf30a3f60289602681351c29093a/pymongo-4.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:c57dad9f289d72af1d7c47a444c4d9fa401f951cedbbcc54c7dd0c2107d6d786", size = 859200, upload-time = "2025-10-07T21:56:26.393Z" },
+ { url = "https://files.pythonhosted.org/packages/81/8f/64c15df5e87de759412c3b962950561202c9b39e5cc604061e056043e163/pymongo-4.15.3-cp311-cp311-win_arm64.whl", hash = "sha256:2fd3b99520f2bb013960ac29dece1b43f2f1b6d94351ca33ba1b1211ecf79a09", size = 848372, upload-time = "2025-10-07T21:56:27.994Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/92/7491a2046b41bfd3641da0a23529c88e27eac67c681de3cd9fbef4113d38/pymongo-4.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bd0497c564b0ae34fb816464ffc09986dd9ca29e2772a0f7af989e472fecc2ad", size = 920953, upload-time = "2025-10-07T21:56:29.737Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/0c/98864cbfa8fbc954ae7480c91a35f0dc4e3339dab0c55f669e4dbeac808f/pymongo-4.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:292fd5a3f045751a823a54cdea75809b2216a62cc5f74a1a96b337db613d46a8", size = 920690, upload-time = "2025-10-07T21:56:31.094Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/a6/7dc8043a10a1c30153be2d6847ab37911b169d53a6b05d21871b35b3de82/pymongo-4.15.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:959ef69c5e687b6b749fbf2140c7062abdb4804df013ae0507caabf30cba6875", size = 1690357, upload-time = "2025-10-07T21:56:32.466Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/96/3d85da60094d2022217f2849e1b61a79af9d51ed8d05455d7413d68ab88e/pymongo-4.15.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de3bc878c3be54ae41c2cabc9e9407549ed4fec41f4e279c04e840dddd7c630c", size = 1726102, upload-time = "2025-10-07T21:56:33.952Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/fd/dfd6ddee0330171f2f52f7e5344c02d25d2dd8dfa95ce0e5e413579f52fd/pymongo-4.15.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07bcc36d11252f24fe671e7e64044d39a13d997b0502c6401161f28cc144f584", size = 1800630, upload-time = "2025-10-07T21:56:35.632Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/3b/e19a5f2de227ff720bc76c41d166d508e6fbe1096ba1ad18ade43b790b5e/pymongo-4.15.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b63bac343b79bd209e830aac1f5d9d552ff415f23a924d3e51abbe3041265436", size = 1785478, upload-time = "2025-10-07T21:56:37.39Z" },
+ { url = "https://files.pythonhosted.org/packages/75/d2/927c9b1383c6708fc50c3700ecb1c2876e67dde95ad5fb1d29d04e8ac083/pymongo-4.15.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b33d59bf6fa1ca1d7d96d4fccff51e41312358194190d53ef70a84c070f5287e", size = 1718548, upload-time = "2025-10-07T21:56:38.754Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/10/a63592d1445f894b18d04865c2d4c235e2261f3d63f31f45ba4fe0486ec4/pymongo-4.15.3-cp312-cp312-win32.whl", hash = "sha256:b3a0ec660d61efb91c16a5962ec937011fe3572c4338216831f102e53d294e5c", size = 891301, upload-time = "2025-10-07T21:56:40.043Z" },
+ { url = "https://files.pythonhosted.org/packages/be/ba/a8fdc43044408ed769c83108fa569aa52ee87968bdbf1e2ea142b109c268/pymongo-4.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:f6b0513e5765fdde39f36e6a29a36c67071122b5efa748940ae51075beb5e4bc", size = 910928, upload-time = "2025-10-07T21:56:41.401Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/61/d53c17fdfaa9149864ab1fa84436ae218b72c969f00e4c124e017e461ce6/pymongo-4.15.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4fdd8e6eab8ff77c1c8041792b5f760d48508623cd10b50d5639e73f1eec049", size = 896347, upload-time = "2025-10-07T21:56:43.271Z" },
+ { url = "https://files.pythonhosted.org/packages/46/a4/e1ce9d408a1c1bcb1554ff61251b108e16cefd7db91b33faa2afc92294de/pymongo-4.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a47a3218f7900f65bf0f36fcd1f2485af4945757360e7e143525db9d715d2010", size = 975329, upload-time = "2025-10-07T21:56:44.674Z" },
+ { url = "https://files.pythonhosted.org/packages/74/3c/6796f653d22be43cc0b13c07dbed84133eebbc334ebed4426459b7250163/pymongo-4.15.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09440e78dff397b2f34a624f445ac8eb44c9756a2688b85b3bf344d351d198e1", size = 975129, upload-time = "2025-10-07T21:56:46.104Z" },
+ { url = "https://files.pythonhosted.org/packages/88/33/22453dbfe11031e89c9cbdfde6405c03960daaf5da1b4dfdd458891846b5/pymongo-4.15.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:97f9babdb98c31676f97d468f7fe2dc49b8a66fb6900effddc4904c1450196c8", size = 1950979, upload-time = "2025-10-07T21:56:47.877Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/07/094598e403112e2410a3376fb7845c69e2ec2dfc5ab5cc00b29dc2d26559/pymongo-4.15.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71413cd8f091ae25b1fec3af7c2e531cf9bdb88ce4079470e64835f6a664282a", size = 1995271, upload-time = "2025-10-07T21:56:49.396Z" },
+ { url = "https://files.pythonhosted.org/packages/47/9a/29e44f3dee68defc56e50ed7c9d3802ebf967ab81fefb175d8d729c0f276/pymongo-4.15.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:76a8d4de8dceb69f6e06736198ff6f7e1149515ef946f192ff2594d2cc98fc53", size = 2086587, upload-time = "2025-10-07T21:56:50.896Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/d5/e9ff16aa57f671349134475b904fd431e7b86e152b01a949aef4f254b2d5/pymongo-4.15.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:77353978be9fc9e5fe56369682efed0aac5f92a2a1570704d62b62a3c9e1a24f", size = 2070201, upload-time = "2025-10-07T21:56:52.425Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/a3/820772c0b2bbb671f253cfb0bede4cf694a38fb38134f3993d491e23ec11/pymongo-4.15.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9897a837677e3814873d0572f7e5d53c23ce18e274f3b5b87f05fb6eea22615b", size = 1985260, upload-time = "2025-10-07T21:56:54.56Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/7b/365ac821aefad7e8d36a4bc472a94429449aade1ccb7805d9ca754df5081/pymongo-4.15.3-cp313-cp313-win32.whl", hash = "sha256:d66da207ccb0d68c5792eaaac984a0d9c6c8ec609c6bcfa11193a35200dc5992", size = 938122, upload-time = "2025-10-07T21:56:55.993Z" },
+ { url = "https://files.pythonhosted.org/packages/80/f3/5ca27e1765fa698c677771a1c0e042ef193e207c15f5d32a21fa5b13d8c3/pymongo-4.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:52f40c4b8c00bc53d4e357fe0de13d031c4cddb5d201e1a027db437e8d2887f8", size = 962610, upload-time = "2025-10-07T21:56:57.397Z" },
+ { url = "https://files.pythonhosted.org/packages/48/7c/42f0b6997324023e94939f8f32b9a8dd928499f4b5d7b4412905368686b5/pymongo-4.15.3-cp313-cp313-win_arm64.whl", hash = "sha256:fb384623ece34db78d445dd578a52d28b74e8319f4d9535fbaff79d0eae82b3d", size = 944300, upload-time = "2025-10-07T21:56:58.969Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/a3/d8aaf9c243ce1319bd2498004a9acccfcfb35a3ef9851abb856993d95255/pymongo-4.15.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dcff15b9157c16bc796765d4d3d151df669322acfb0357e4c3ccd056153f0ff4", size = 1029873, upload-time = "2025-10-07T21:57:00.759Z" },
+ { url = "https://files.pythonhosted.org/packages/64/10/91fd7791425ed3b56cbece6c23a36fb2696706a695655d8ea829e5e23c3a/pymongo-4.15.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1f681722c9f27e86c49c2e8a838e61b6ecf2285945fd1798bd01458134257834", size = 1029611, upload-time = "2025-10-07T21:57:02.488Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/9c/d9cf8d8a181f96877bca7bdec3e6ce135879d5e3d78694ea465833c53a3f/pymongo-4.15.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2c96dde79bdccd167b930a709875b0cd4321ac32641a490aebfa10bdcd0aa99b", size = 2211827, upload-time = "2025-10-07T21:57:03.907Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/40/12703964305216c155284100124222eaa955300a07d426c6e0ba3c9cbade/pymongo-4.15.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2d4ca446348d850ac4a5c3dc603485640ae2e7805dbb90765c3ba7d79129b37", size = 2264654, upload-time = "2025-10-07T21:57:05.41Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/70/bf3c18b5d0cae0b9714158b210b07b5891a875eb1c503271cfe045942fd3/pymongo-4.15.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0fd3de3a12ff0a8113a3f64cedb01f87397ab8eaaffa88d7f18ca66cd39385", size = 2371830, upload-time = "2025-10-07T21:57:06.9Z" },
+ { url = "https://files.pythonhosted.org/packages/21/6d/2dfaed2ae66304ab842d56ed9a1bd2706ca0ecf97975b328a5eeceb2a4c0/pymongo-4.15.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e84dec392cf5f72d365e0aac73f627b0a3170193ebb038c3f7e7df11b7983ee7", size = 2351878, upload-time = "2025-10-07T21:57:08.92Z" },
+ { url = "https://files.pythonhosted.org/packages/17/ed/fe46ff9adfa6dc11ad2e0694503adfc98f40583cfcc6db4dbaf582f0e357/pymongo-4.15.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d4b01a48369ea6d5bc83fea535f56279f806aa3e4991189f0477696dd736289", size = 2251356, upload-time = "2025-10-07T21:57:10.51Z" },
+ { url = "https://files.pythonhosted.org/packages/12/c4/2e1a10b1e9bca9c106f2dc1b89d4ad70c63d387c194b3a1bfcca552b5a3f/pymongo-4.15.3-cp314-cp314-win32.whl", hash = "sha256:3561fa96c3123275ec5ccf919e595547e100c412ec0894e954aa0da93ecfdb9e", size = 992878, upload-time = "2025-10-07T21:57:12.119Z" },
+ { url = "https://files.pythonhosted.org/packages/98/b5/14aa417a44ea86d4c31de83b26f6e6793f736cd60e7e7fda289ce5184bdf/pymongo-4.15.3-cp314-cp314-win_amd64.whl", hash = "sha256:9df2db6bd91b07400879b6ec89827004c0c2b55fc606bb62db93cafb7677c340", size = 1021209, upload-time = "2025-10-07T21:57:13.686Z" },
+ { url = "https://files.pythonhosted.org/packages/94/9f/1097c6824fa50a4ffb11ba5194d2a9ef68d5509dd342e32ddb697d2efe4e/pymongo-4.15.3-cp314-cp314-win_arm64.whl", hash = "sha256:ff99864085d2c7f4bb672c7167680ceb7d273e9a93c1a8074c986a36dbb71cc6", size = 1000618, upload-time = "2025-10-07T21:57:15.212Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/31/37c76607a4f793f4491611741fa7a7c4238b956f48c4a9505cea0b5cf7ef/pymongo-4.15.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ffe217d2502f3fba4e2b0dc015ce3b34f157b66dfe96835aa64432e909dd0d95", size = 1086576, upload-time = "2025-10-07T21:57:16.742Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b2/6d17d279cdd293eeeb0c9d5baeb4f8cdebb45354fd81cfcef2d1c69303ab/pymongo-4.15.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:390c4954c774eda280898e73aea36482bf20cba3ecb958dbb86d6a68b9ecdd68", size = 1086656, upload-time = "2025-10-07T21:57:18.774Z" },
+ { url = "https://files.pythonhosted.org/packages/55/fd/c5da8619beca207d7e6231f24ed269cb537c5311dad59fd9f2ef7d43204a/pymongo-4.15.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7dd2a49f088890ca08930bbf96121443b48e26b02b84ba0a3e1ae2bf2c5a9b48", size = 2531646, upload-time = "2025-10-07T21:57:20.63Z" },
+ { url = "https://files.pythonhosted.org/packages/93/8f/66a7e12b874f41eb205f352b3a719e5a964b5ba103996f6ac45e80560111/pymongo-4.15.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f6feb678f26171f2a6b2cbb340949889154c7067972bd4cc129b62161474f08", size = 2603799, upload-time = "2025-10-07T21:57:22.591Z" },
+ { url = "https://files.pythonhosted.org/packages/10/98/baf0d1f8016087500899cc4ae14e591f29b016c643e99ab332fcafe6f7bc/pymongo-4.15.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:446417a34ff6c2411ce3809e17ce9a67269c9f1cb4966b01e49e0c590cc3c6b3", size = 2725238, upload-time = "2025-10-07T21:57:24.091Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/a2/112d8d3882d6e842f501e166fbe08dfc2bc9a35f8773cbcaa804f7991043/pymongo-4.15.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cfa4a0a0f024a0336640e1201994e780a17bda5e6a7c0b4d23841eb9152e868b", size = 2704837, upload-time = "2025-10-07T21:57:25.626Z" },
+ { url = "https://files.pythonhosted.org/packages/38/fe/043a9aac7b3fba5b8e216f48359bd18fdbe46a4d93b081786f773b25e997/pymongo-4.15.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b03db2fe37c950aff94b29ded5c349b23729bccd90a0a5907bbf807d8c77298", size = 2582294, upload-time = "2025-10-07T21:57:27.221Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/fe/7a6a6b331d9f2024ab171028ab53d5d9026959b1d713fe170be591a4d9a8/pymongo-4.15.3-cp314-cp314t-win32.whl", hash = "sha256:e7cde58ef6470c0da922b65e885fb1ffe04deef81e526bd5dea429290fa358ca", size = 1043993, upload-time = "2025-10-07T21:57:28.727Z" },
+ { url = "https://files.pythonhosted.org/packages/70/c8/bc64321711e19bd48ea3371f0082f10295c433833245d73e7606d3b9afbe/pymongo-4.15.3-cp314-cp314t-win_amd64.whl", hash = "sha256:fae552767d8e5153ed498f1bca92d905d0d46311d831eefb0f06de38f7695c95", size = 1078481, upload-time = "2025-10-07T21:57:30.372Z" },
+ { url = "https://files.pythonhosted.org/packages/39/31/2bb2003bb978eb25dfef7b5f98e1c2d4a86e973e63b367cc508a9308d31c/pymongo-4.15.3-cp314-cp314t-win_arm64.whl", hash = "sha256:47ffb068e16ae5e43580d5c4e3b9437f05414ea80c32a1e5cac44a835859c259", size = 1051179, upload-time = "2025-10-07T21:57:31.829Z" },
+ { url = "https://files.pythonhosted.org/packages/30/80/9e3418cf7f76e6152af2b1374e1b0a2d45bb7b5258dbeef9e9f81ac9caca/pymongo-4.15.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:58d0f4123855f05c0649f9b8ee083acc5b26e7f4afde137cd7b8dc03e9107ff3", size = 756766, upload-time = "2025-10-07T21:57:33.217Z" },
+ { url = "https://files.pythonhosted.org/packages/03/6d/8c7536a52fd0bd658de3b29862363e3a5b60703fd033539d4550f63e0a26/pymongo-4.15.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9bc9f99e7702fdb0dcc3ff1dd490adc5d20b3941ad41e58f887d4998b9922a14", size = 757118, upload-time = "2025-10-07T21:57:34.67Z" },
+ { url = "https://files.pythonhosted.org/packages/65/2d/214167c25bbd9e71f81de87706a8a7850cfcf603acca7d890f280ab00945/pymongo-4.15.3-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:86b1b5b63f4355adffc329733733a9b71fdad88f37a9dc41e163aed2130f9abc", size = 943612, upload-time = "2025-10-07T21:57:36.064Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/00/e8ee9703c38329204d5ba3ba3272f1c672e246e76593c8cbe213fbdd0b26/pymongo-4.15.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a054d282dd922ac400b6f47ea3ef58d8b940968d76d855da831dc739b7a04de", size = 952831, upload-time = "2025-10-07T21:57:38.167Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/18/9d8d611780263091e55f565bac82f03e9f5d1a29742c30bb9a04037ff420/pymongo-4.15.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc583a1130e2516440b93bb2ecb55cfdac6d5373615ae472a9d1f26801f58749", size = 972024, upload-time = "2025-10-07T21:57:40.581Z" },
+ { url = "https://files.pythonhosted.org/packages/52/4e/713bcad0bcf6cefa180e168d6bfa776b6967cc7158923905bbcfdd8706c7/pymongo-4.15.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c78237e878e0296130e398151b0d4aa6c9eaf82e38fb6e0aaae2029bc7ef0ce", size = 966966, upload-time = "2025-10-07T21:57:42.13Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/e5/b7be85a66a720c8886b2d31921dc712ecf4af8a583ddd88febbd1548a0b9/pymongo-4.15.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c85a4c72b7965033f95c94c42dac27d886c01dbc23fe337ccb14f052a0ccc29", size = 950659, upload-time = "2025-10-07T21:57:43.63Z" },
+ { url = "https://files.pythonhosted.org/packages/34/06/f7800d870339587694c82a535f32cc9764d9d778c6898ce187c485dc5f4b/pymongo-4.15.3-cp39-cp39-win32.whl", hash = "sha256:17fc94d1e067556b122eeb09e25c003268e8c0ea1f2f78e745b33bb59a1209c4", size = 752178, upload-time = "2025-10-07T21:57:45.045Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/ed/a6257e6c0d74a5580123dfcb146b9270e1cf754295296c8626d648c4164d/pymongo-4.15.3-cp39-cp39-win_amd64.whl", hash = "sha256:5bf879a6ed70264574d4d8fb5a467c2a64dc76ecd72c0cb467c4464f849c8c77", size = 757107, upload-time = "2025-10-07T21:57:46.7Z" },
+ { url = "https://files.pythonhosted.org/packages/14/fb/f7337880c54665068f8a3a14346829c765476ad981ce05d69de16e15840c/pymongo-4.15.3-cp39-cp39-win_arm64.whl", hash = "sha256:2f3d66f7c495efc3cfffa611b36075efe86da1860a7df75522a6fe499ee10383", size = 753494, upload-time = "2025-10-07T21:57:48.548Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.10'",
+]
dependencies = [
- { name = "dnspython" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/74/0c/1fb60383ab4b20566407b87f1a95b7f5cda83e8d5594da6fc84e2a543405/pymongo-4.13.0.tar.gz", hash = "sha256:92a06e3709e3c7e50820d352d3d4e60015406bcba69808937dac2a6d22226fde", size = 2166443, upload-time = "2025-05-14T19:11:08.649Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4f/82/b19f8f3d1b78e432e1e2a6a2da4dd7bbf5535bce704c69ab14ea598e1af5/pymongo-4.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fe497c885b08600a022646f00f4d3303697c5289990acec250e2be2e1699ca23", size = 802525, upload-time = "2025-05-14T19:09:19.767Z" },
- { url = "https://files.pythonhosted.org/packages/f7/d6/1a95ce6af3eb8398d8cadaf9b1429acb1c51ad62c7ec1be77606649f7543/pymongo-4.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2d377bb0811e0a9676bacb21a4f87ef307f2e9a40a625660c113a9c0ae897e8c", size = 802817, upload-time = "2025-05-14T19:09:21.573Z" },
- { url = "https://files.pythonhosted.org/packages/d0/bb/4b2f2013127ff36b6f7f567f714f3d4452dce4559abe236ea98d0626e9e6/pymongo-4.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bac84ee40032bec4c089e92970893157fcd0ef40b81157404ceb4c1dac8ba72", size = 1180183, upload-time = "2025-05-14T19:09:23.63Z" },
- { url = "https://files.pythonhosted.org/packages/b9/32/f15f0b509797307905deadc7227aa83d824f4a2b419461534e8c25ef0149/pymongo-4.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ea47a64ed9918be0fa8a4a11146a80f546c09e0d65fd08e90a5c00366a59bdb0", size = 1214410, upload-time = "2025-05-14T19:09:25.63Z" },
- { url = "https://files.pythonhosted.org/packages/7e/07/45a34e640cb863ddc514ee06845ea2c33312750eba6bbd6e5ab763eabf46/pymongo-4.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e90195cb5aee24a67a29adde54c1dae4d9744e17e4585bea3a83bfff96db46c", size = 1197340, upload-time = "2025-05-14T19:09:27.667Z" },
- { url = "https://files.pythonhosted.org/packages/70/59/ccab966891d536ff3efa36a111d424850efc96364412a117fda2acca9d17/pymongo-4.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4a7855933011026898ea0d4532fbd83cef63a76205c823a4ef5557d970df1f1", size = 1183356, upload-time = "2025-05-14T19:09:29.218Z" },
- { url = "https://files.pythonhosted.org/packages/3c/c7/fe84905f49cca6500dab25554f914dfb0ef95e77faa0cd87e4c5e1a81cbb/pymongo-4.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f39791a88cd5ec1760f65e878af419747c6f94ce74f9293735cbba6025ff4d0d", size = 1162513, upload-time = "2025-05-14T19:09:31.738Z" },
- { url = "https://files.pythonhosted.org/packages/87/aa/2752c1cdf67812703812199d4bbad2632ed801aa4736ebada43947dbf3ae/pymongo-4.13.0-cp310-cp310-win32.whl", hash = "sha256:209efd3b62cdbebd3cc7a76d5e37414ad08c9bfe8b28ae73695ade065d5b1277", size = 788539, upload-time = "2025-05-14T19:09:33.823Z" },
- { url = "https://files.pythonhosted.org/packages/6b/ab/cc9ff174b4e60c02482685cdf7b76cfbe47640a46e2c026b987d8baded4f/pymongo-4.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:51081910a91e3451db74b7265ee290c72220412aa8897d6dfe28f6e5d80b685b", size = 797871, upload-time = "2025-05-14T19:09:35.3Z" },
- { url = "https://files.pythonhosted.org/packages/27/21/422381c97454a56021c50f776847c1db6082f84a0944dda3823ef76b4860/pymongo-4.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46c8bce9af98556110a950939f3eaa3f7648308d60df65feb783c780f8b9bfa9", size = 856909, upload-time = "2025-05-14T19:09:37.257Z" },
- { url = "https://files.pythonhosted.org/packages/c3/e6/b34ab65ad524bc34dc3aa634d3dc411f65c495842ebb25b2d8593fc4bbed/pymongo-4.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc9e412911f210d9b0eca42d25c22d3725809dda03dedbaf6f9ffa192d461905", size = 857202, upload-time = "2025-05-14T19:09:38.862Z" },
- { url = "https://files.pythonhosted.org/packages/ff/62/17d3f8ff1d2ff67d3ed2985fdf616520362cfe4ae3802df0e9601d5686c9/pymongo-4.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9288188101506a9d1aa3f70f65b7f5f499f8f7d5c23ec86a47551d756e32059", size = 1426272, upload-time = "2025-05-14T19:09:41.103Z" },
- { url = "https://files.pythonhosted.org/packages/51/e2/22582d886d5a382fb605b3025047d75ec38f497cddefe86e29fca39c4363/pymongo-4.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5303e2074b85234e337ebe622d353ce38a35696cd47a7d970f84b545288aee01", size = 1477235, upload-time = "2025-05-14T19:09:43.099Z" },
- { url = "https://files.pythonhosted.org/packages/bd/e3/10bce21b8c0bf954c144638619099012a3e247c7d009df044f450fbaf340/pymongo-4.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d842e11eb94f7074314ff1d97a05790539a1d74c3048ce50ea9f0da1f4f96b0a", size = 1451677, upload-time = "2025-05-14T19:09:45.417Z" },
- { url = "https://files.pythonhosted.org/packages/30/10/4c54a4adf90a04e6147260e16f9cfeab11cb661d71ddd12a98449a279977/pymongo-4.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b63d9d8be87f4be11972c5a63d815974c298ada59a2e1d56ef5b6984d81c544a", size = 1430799, upload-time = "2025-05-14T19:09:47.516Z" },
- { url = "https://files.pythonhosted.org/packages/86/52/99620c5e106663a3679541b2316e0631b39cb49a6be14291597b28a8b428/pymongo-4.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d740560710be0c514bc9d26f5dcbb3c85dbb6b450c4c3246d8136ca84055bd", size = 1399450, upload-time = "2025-05-14T19:09:49.095Z" },
- { url = "https://files.pythonhosted.org/packages/f1/23/73d0379e46f98eed5339b6d44527e366b553c39327c69ba543f7beafb237/pymongo-4.13.0-cp311-cp311-win32.whl", hash = "sha256:936f7be9ed6919e3be7369b858d1c58ebaa4f3ef231cf4860779b8ba3b4fcd11", size = 834134, upload-time = "2025-05-14T19:09:50.682Z" },
- { url = "https://files.pythonhosted.org/packages/45/bd/d6286b923e852dc080330182a8b57023555870d875b7523454ad1bdd1579/pymongo-4.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a8f060f8ad139d1d45f75ef7aa0084bd7f714fc666f98ef00009efc7db34acd", size = 848068, upload-time = "2025-05-14T19:09:52.778Z" },
- { url = "https://files.pythonhosted.org/packages/42/5e/db6871892ec41860339e94e20fabce664b64c193636dc69b572503382f12/pymongo-4.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:007450b8c8d17b4e5b779ab6e1938983309eac26b5b8f0863c48effa4b151b07", size = 911769, upload-time = "2025-05-14T19:09:54.483Z" },
- { url = "https://files.pythonhosted.org/packages/86/8b/6960dc8baf2b6e1b809513160913e90234160c5df2dc1f2baf1cf1d25ac9/pymongo-4.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:899a5ea9cd32b1b0880015fdceaa36a41140a8c2ce8621626c52f7023724aed6", size = 911464, upload-time = "2025-05-14T19:09:56.253Z" },
- { url = "https://files.pythonhosted.org/packages/41/fb/d682bf1c4cb656f47616796f707a1316862f71b3c1899cb6b6806803dff6/pymongo-4.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0b26cd4e090161927b7a81741a3627a41b74265dfb41c6957bfb474504b4b42", size = 1690111, upload-time = "2025-05-14T19:09:58.331Z" },
- { url = "https://files.pythonhosted.org/packages/03/d4/0047767ee5b6c66e4b5b67a5d85de14da9910ee8f7d8159e7c1d5d627358/pymongo-4.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b54e19e0f6c8a7ad0c5074a8cbefb29c12267c784ceb9a1577a62bbc43150161", size = 1754348, upload-time = "2025-05-14T19:10:00.088Z" },
- { url = "https://files.pythonhosted.org/packages/7c/ea/e64f2501eaca552b0f303c2eb828c69963c8bf1a663111686a900502792d/pymongo-4.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6208b83e7d566935218c0837f3b74c7d2dda83804d5d843ce21a55f22255ab74", size = 1723390, upload-time = "2025-05-14T19:10:02.28Z" },
- { url = "https://files.pythonhosted.org/packages/d1/5c/fad80bc263281c8b819ce29ed1d88c2023c5576ecc608d15ca1628078e29/pymongo-4.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f33b8c1405d05517dce06756f2800b37dd098216cae5903cd80ad4f0a9dad08", size = 1693367, upload-time = "2025-05-14T19:10:04.405Z" },
- { url = "https://files.pythonhosted.org/packages/c1/3d/4ff09614c996f8574d36008763b9fc01532ec7e954b5edde9254455b279b/pymongo-4.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02f0e1af87280697a1a8304238b863d4eee98c8b97f554ee456c3041c0f3a021", size = 1652496, upload-time = "2025-05-14T19:10:06.528Z" },
- { url = "https://files.pythonhosted.org/packages/f2/2f/c4e54ac337e0ad3d91aae7de59849aaed28de6340112da2e2427f5e0c689/pymongo-4.13.0-cp312-cp312-win32.whl", hash = "sha256:5dea2f6b44697eda38a11ef754d2adfff5373c51b1ffda00b9fedc5facbd605f", size = 880497, upload-time = "2025-05-14T19:10:08.626Z" },
- { url = "https://files.pythonhosted.org/packages/6a/43/6595a52fe144bb0dae4d592e49c6c909f98033c4fa2eaa544b13e22ac6e8/pymongo-4.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:c03e02129ad202d8e146480b398c4a3ea18266ee0754b6a4805de6baf4a6a8c7", size = 898742, upload-time = "2025-05-14T19:10:10.214Z" },
- { url = "https://files.pythonhosted.org/packages/5a/dc/9afa6091bce4adad7cad736dcdc35c139a9b551fc61032ef20c7ba17eae5/pymongo-4.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92f5e75ae265e798be1a8a40a29e2ab934e156f3827ca0e1c47e69d43f4dcb31", size = 965996, upload-time = "2025-05-14T19:10:12.319Z" },
- { url = "https://files.pythonhosted.org/packages/36/69/e4242abffc0ee1501bb426d8a540e712e4f917491735f18622838b17f5a1/pymongo-4.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3d631d879e934b46222f5092d8951cbb9fe83542649697c8d342ea7b5479f118", size = 965702, upload-time = "2025-05-14T19:10:14.051Z" },
- { url = "https://files.pythonhosted.org/packages/fc/3e/0732876b48b1285bada803f4b0d7da5b720cf8f778d2117bbed9e04473a3/pymongo-4.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be048fb78e165243272a8cdbeb40d53eace82424b95417ab3ab6ec8e9b00c59b", size = 1953825, upload-time = "2025-05-14T19:10:16.214Z" },
- { url = "https://files.pythonhosted.org/packages/dc/3b/6713fed92cab64508a1fb8359397c0720202e5f36d7faf4ed71b05875180/pymongo-4.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d81d159bd23d8ac53a6e819cccee991cb9350ab2541dfaa25aeb2f712d23b0a5", size = 2031179, upload-time = "2025-05-14T19:10:18.307Z" },
- { url = "https://files.pythonhosted.org/packages/89/2b/1aad904563c312a0dc2ff752acf0f11194f836304d6e15d05dff3a33df08/pymongo-4.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8af08ba2886f08d334bc7e5d5c662c60ea2f16e813a2c35106f399463fa11087", size = 1995093, upload-time = "2025-05-14T19:10:20.089Z" },
- { url = "https://files.pythonhosted.org/packages/4c/cc/33786f4ce9a46c776f0d32601b353f8c42b552ea9ff8060c290c912b661e/pymongo-4.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b91f59137e46cd3ff17d5684a18e8006d65d0ee62eb1068b512262d1c2c5ae8", size = 1955820, upload-time = "2025-05-14T19:10:21.788Z" },
- { url = "https://files.pythonhosted.org/packages/2d/dd/9a2a87bd4aab12a2281ac20d179912eed824cc6f67df49edd87fa4879b3e/pymongo-4.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:61733c8f1ded90ab671a08033ee99b837073c73e505b3b3b633a55a0326e77f4", size = 1905394, upload-time = "2025-05-14T19:10:23.684Z" },
- { url = "https://files.pythonhosted.org/packages/04/be/0a70db5e4c4e1c162207e31eaa3debf98476e0265b154f6d2252f85969b0/pymongo-4.13.0-cp313-cp313-win32.whl", hash = "sha256:d10d3967e87c21869f084af5716d02626a17f6f9ccc9379fcbece5821c2a9fb4", size = 926840, upload-time = "2025-05-14T19:10:25.505Z" },
- { url = "https://files.pythonhosted.org/packages/dd/a6/fb104175a7f15dd69691c8c32bd4b99c4338ec89fe094b6895c940cf2afb/pymongo-4.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9fe172e93551ddfdb94b9ad34dccebc4b7b680dc1d131bc6bd661c4a5b2945c", size = 949383, upload-time = "2025-05-14T19:10:27.234Z" },
- { url = "https://files.pythonhosted.org/packages/62/3f/c89a6121b0143fde431f04c267a0d49159b499f518630a43aa6288709749/pymongo-4.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:5adc1349fd5c94d5dfbcbd1ad9858d1df61945a07f5905dcf17bb62eb4c81f93", size = 1022500, upload-time = "2025-05-14T19:10:29.002Z" },
- { url = "https://files.pythonhosted.org/packages/4b/89/8fc36b83768b44805dd3a1caf755f019b110d2111671950b39c8c7781cd9/pymongo-4.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8e11ea726ff8ddc8c8393895cd7e93a57e2558c27273d3712797895c53d25692", size = 1022503, upload-time = "2025-05-14T19:10:30.757Z" },
- { url = "https://files.pythonhosted.org/packages/67/dc/f216cf6218f8ceb4025fd10e3de486553bd5373c3b71a45fef3483b745bb/pymongo-4.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c02160ab3a67eca393a2a2bb83dccddf4db2196d0d7c6a980a55157e4bdadc06", size = 2282184, upload-time = "2025-05-14T19:10:32.699Z" },
- { url = "https://files.pythonhosted.org/packages/56/32/08a9045dbcd76a25d36a0bd42c635b56d9aed47126bcca0e630a63e08444/pymongo-4.13.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fca24e4df05501420b2ce2207c03f21fcbdfac1e3f41e312e61b8f416c5b4963", size = 2369224, upload-time = "2025-05-14T19:10:34.942Z" },
- { url = "https://files.pythonhosted.org/packages/16/63/7991853fa6cf5e52222f8f480081840fb452d78c1dcd6803cabe2d3557a6/pymongo-4.13.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:50c503b7e809e54740704ec4c87a0f2ccdb910c3b1d36c07dbd2029b6eaa6a50", size = 2328611, upload-time = "2025-05-14T19:10:36.791Z" },
- { url = "https://files.pythonhosted.org/packages/e9/0f/11beecc8d48c7549db3f13f2101fd1c06ccb668697d33a6a5a05bb955574/pymongo-4.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66800de4f4487e7c437991b44bc1e717aadaf06e67451a760efe5cd81ce86575", size = 2279806, upload-time = "2025-05-14T19:10:38.652Z" },
- { url = "https://files.pythonhosted.org/packages/17/a7/0358efc8dba796545e9bd4642d1337a9b67b60928c583799fb0726594855/pymongo-4.13.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82c36928c1c26580ce4f2497a6875968636e87c77108ff253d76b1355181a405", size = 2219131, upload-time = "2025-05-14T19:10:40.444Z" },
- { url = "https://files.pythonhosted.org/packages/58/d5/373cd1cd21eff769e22e4e0924dcbfd770dfa1298566d51a7097857267fc/pymongo-4.13.0-cp313-cp313t-win32.whl", hash = "sha256:1397eac713b84946210ab556666cfdd787eee824e910fbbe661d147e110ec516", size = 975711, upload-time = "2025-05-14T19:10:42.213Z" },
- { url = "https://files.pythonhosted.org/packages/b0/39/1e204091bdf264a0d9eccc21f7da099903a7a30045f055a91178686c0259/pymongo-4.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:99a52cfbf31579cc63c926048cd0ada6f96c98c1c4c211356193e07418e6207c", size = 1004287, upload-time = "2025-05-14T19:10:45.468Z" },
- { url = "https://files.pythonhosted.org/packages/84/e2/6b2bced59dba2e9108263821f6141d7742e8e9ef84c1e1b15dff6ee223bc/pymongo-4.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:267eff6a66da5cf5255b3bcd257984619e9c4d41a53578d4e1d827553a51cf40", size = 748144, upload-time = "2025-05-14T19:10:47.223Z" },
- { url = "https://files.pythonhosted.org/packages/84/a8/90aa028f3d2b8f498fac1768257d9493c7a986b936fe3d5b9dfd1616b9e0/pymongo-4.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:81b46d9bc62128c3d968336f8635bcfce33d8e9e1fc6be6ebdfb98effaccb9c7", size = 748431, upload-time = "2025-05-14T19:10:48.947Z" },
- { url = "https://files.pythonhosted.org/packages/92/dc/a643356995ac036f86f35e0e43cb1412e4f5b3128ae2398b208a9e8ef108/pymongo-4.13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd0c9322fdf1b9e8a5c99ca337bd9a99d972ba57c976e77b5017366ba26725e1", size = 936094, upload-time = "2025-05-14T19:10:50.853Z" },
- { url = "https://files.pythonhosted.org/packages/7e/27/96e5dcfac38a9ebadb9c3b740f6660e6ed9372ccb5b62ab5efda35aeb299/pymongo-4.13.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4b4942e5566a134fe34c03d7182a0b346e4a478defe625dc430dd5a178ad96e", size = 953437, upload-time = "2025-05-14T19:10:52.689Z" },
- { url = "https://files.pythonhosted.org/packages/f9/c5/a576862c4355cf350b8a66f0a079ed5858d52e123b93e71a9ec5f52f2ab5/pymongo-4.13.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cef461fae88ac51cd6b3f81adf58171113c58c0e77c82c751b3bdcef516cfeb1", size = 945102, upload-time = "2025-05-14T19:10:54.641Z" },
- { url = "https://files.pythonhosted.org/packages/77/11/e7a104523f40739809560675cfdd753a8524c53aeb9583c2a20481e7f068/pymongo-4.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb780d9d284ffdf7922edd4a6d7ba08e54a6680f85f64f91fa9cc2617dd488b7", size = 937994, upload-time = "2025-05-14T19:10:56.438Z" },
- { url = "https://files.pythonhosted.org/packages/c0/e6/6d23ee9aebccd85878f1f2851cf89e9c4090dd544dd2bb2fbc93d1131105/pymongo-4.13.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2afe49109b4d498d8e55ac9692915f2a3fce0bd31646bb7ed41f9ab3546ca19", size = 927655, upload-time = "2025-05-14T19:10:58.755Z" },
- { url = "https://files.pythonhosted.org/packages/42/b4/9a33412c98774f9c79157acffe50786a9aa4781dca5b70b779eaa3d75cc5/pymongo-4.13.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d9a1d7d49d0d364520894116133d017b6e0e2d5131eb31c8553552fa77a65085", size = 910905, upload-time = "2025-05-14T19:11:00.593Z" },
- { url = "https://files.pythonhosted.org/packages/ac/45/0bc5ebe2e573d5945f15806b95bda4f1e816b64a7b9feefb555c342c10db/pymongo-4.13.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:d684d9b385d97ab821d2ae74628c81a8bd12a4e5004a3ded0ec8c20381d62d0e", size = 937307, upload-time = "2025-05-14T19:11:02.636Z" },
- { url = "https://files.pythonhosted.org/packages/5a/2c/7bbbb4c8aa758f2c86005146f5ebd0c5ffeaf420f6f7f21e526c03efd4d1/pymongo-4.13.0-cp39-cp39-win32.whl", hash = "sha256:bd23119f9d0358aa1f78174d2eda88ca5c882a722e25ca31197402278acddc6e", size = 742944, upload-time = "2025-05-14T19:11:04.46Z" },
- { url = "https://files.pythonhosted.org/packages/81/d3/372eecea4ac8629a215e9f2e387d6d73e4a7698a4fcfaeb478f843c217fb/pymongo-4.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:e7d349066f4c229d638a30f1f53ec3a4aaf4a4fc568491bdf77e7415a96003fb", size = 747675, upload-time = "2025-05-14T19:11:06.839Z" },
+ { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.10'" },
+ { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "packaging", version = "24.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "pluggy", marker = "python_full_version < '3.10'" },
+ { name = "pygments", marker = "python_full_version < '3.10'" },
+ { name = "tomli", marker = "python_full_version < '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "pytest"
-version = "8.3.5"
+version = "9.0.0"
source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14'",
+ "python_full_version >= '3.10' and python_full_version < '3.14'",
+]
dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
- { name = "iniconfig" },
- { name = "packaging" },
- { name = "pluggy" },
- { name = "tomli", marker = "python_full_version < '3.11'" },
+ { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
+ { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
+ { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "packaging", version = "25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "pluggy", marker = "python_full_version >= '3.10'" },
+ { name = "pygments", marker = "python_full_version >= '3.10'" },
+ { name = "tomli", marker = "python_full_version == '3.10.*'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/1d/eb34f286b164c5e431a810a38697409cca1112cee04b287bb56ac486730b/pytest-9.0.0.tar.gz", hash = "sha256:8f44522eafe4137b0f35c9ce3072931a788a21ee40a2ed279e817d3cc16ed21e", size = 1562764, upload-time = "2025-11-08T17:25:33.34Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" },
+ { url = "https://files.pythonhosted.org/packages/72/99/cafef234114a3b6d9f3aaed0723b437c40c57bdb7b3e4c3a575bc4890052/pytest-9.0.0-py3-none-any.whl", hash = "sha256:e5ccdf10b0bac554970ee88fc1a4ad0ee5d221f8ef22321f9b7e4584e19d7f96", size = 373364, upload-time = "2025-11-08T17:25:31.811Z" },
]
[[package]]
@@ -1181,7 +1817,8 @@ version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
- { name = "pytest" },
+ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "pytest", version = "9.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/23/32b36d2f769805c0f3069ca8d9eeee77b27fcf86d41d40c6061ddce51c7d/pytest-flask-1.3.0.tar.gz", hash = "sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e", size = 35816, upload-time = "2023-10-23T14:53:20.696Z" }
@@ -1191,23 +1828,24 @@ wheels = [
[[package]]
name = "pytest-mock"
-version = "3.14.1"
+version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pytest" },
+ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "pytest", version = "9.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]]
name = "python-dotenv"
-version = "1.1.0"
+version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" },
+ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
@@ -1221,19 +1859,19 @@ wheels = [
[[package]]
name = "redis"
-version = "6.2.0"
+version = "7.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ea/9a/0551e01ba52b944f97480721656578c8a7c46b51b99d66814f85fe3a4f3e/redis-6.2.0.tar.gz", hash = "sha256:e821f129b75dde6cb99dd35e5c76e8c49512a5a0d8dfdc560b2fbd44b85ca977", size = 4639129, upload-time = "2025-05-28T05:01:18.91Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/8f/f125feec0b958e8d22c8f0b492b30b1991d9499a4315dfde466cf4289edc/redis-7.0.1.tar.gz", hash = "sha256:c949df947dca995dc68fdf5a7863950bf6df24f8d6022394585acc98e81624f1", size = 4755322, upload-time = "2025-10-27T14:34:00.33Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/13/67/e60968d3b0e077495a8fee89cf3f2373db98e528288a48f1ee44967f6e8c/redis-6.2.0-py3-none-any.whl", hash = "sha256:c8ddf316ee0aab65f04a11229e94a64b2618451dab7a67cb2f77eb799d872d5e", size = 278659, upload-time = "2025-05-28T05:01:16.955Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/97/9f22a33c475cda519f20aba6babb340fb2f2254a02fb947816960d1e669a/redis-7.0.1-py3-none-any.whl", hash = "sha256:4977af3c7d67f8f0eb8b6fec0dafc9605db9343142f634041fb0235f67c0588a", size = 339938, upload-time = "2025-10-27T14:33:58.553Z" },
]
[[package]]
name = "requests"
-version = "2.32.3"
+version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -1241,21 +1879,21 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "requests-file"
-version = "2.1.0"
+version = "3.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/72/97/bf44e6c6bd8ddbb99943baf7ba8b1a8485bcd2fe0e55e5708d7fee4ff1ae/requests_file-2.1.0.tar.gz", hash = "sha256:0f549a3f3b0699415ac04d167e9cb39bccfb730cb832b4d20be3d9867356e658", size = 6891, upload-time = "2024-05-21T16:28:00.24Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/3c/f8/5dc70102e4d337063452c82e1f0d95e39abfe67aa222ed8a5ddeb9df8de8/requests_file-3.0.1.tar.gz", hash = "sha256:f14243d7796c588f3521bd423c5dea2ee4cc730e54a3cac9574d78aca1272576", size = 6967, upload-time = "2025-10-20T18:56:42.279Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d7/25/dd878a121fcfdf38f52850f11c512e13ec87c2ea72385933818e5b6c15ce/requests_file-2.1.0-py2.py3-none-any.whl", hash = "sha256:cf270de5a4c5874e84599fc5778303d496c10ae5e870bfa378818f35d21bda5c", size = 4244, upload-time = "2024-05-21T16:27:57.733Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/d5/de8f089119205a09da657ed4784c584ede8381a0ce6821212a6d4ca47054/requests_file-3.0.1-py2.py3-none-any.whl", hash = "sha256:d0f5eb94353986d998f80ac63c7f146a307728be051d4d1cd390dbdb59c10fa2", size = 4514, upload-time = "2025-10-20T18:56:41.184Z" },
]
[[package]]
@@ -1274,67 +1912,117 @@ wheels = [
name = "rich"
version = "13.9.4"
source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.10'",
+]
dependencies = [
- { name = "markdown-it-py" },
- { name = "pygments" },
- { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "pygments", marker = "python_full_version < '3.10'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" },
]
+[[package]]
+name = "rich"
+version = "14.2.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14'",
+ "python_full_version >= '3.10' and python_full_version < '3.14'",
+]
+dependencies = [
+ { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "pygments", marker = "python_full_version >= '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
+]
+
[[package]]
name = "ruff"
-version = "0.11.12"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/15/0a/92416b159ec00cdf11e5882a9d80d29bf84bba3dbebc51c4898bfbca1da6/ruff-0.11.12.tar.gz", hash = "sha256:43cf7f69c7d7c7d7513b9d59c5d8cafd704e05944f978614aa9faff6ac202603", size = 4202289, upload-time = "2025-05-29T13:31:40.037Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/60/cc/53eb79f012d15e136d40a8e8fc519ba8f55a057f60b29c2df34efd47c6e3/ruff-0.11.12-py3-none-linux_armv6l.whl", hash = "sha256:c7680aa2f0d4c4f43353d1e72123955c7a2159b8646cd43402de6d4a3a25d7cc", size = 10285597, upload-time = "2025-05-29T13:30:57.539Z" },
- { url = "https://files.pythonhosted.org/packages/e7/d7/73386e9fb0232b015a23f62fea7503f96e29c29e6c45461d4a73bac74df9/ruff-0.11.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2cad64843da9f134565c20bcc430642de897b8ea02e2e79e6e02a76b8dcad7c3", size = 11053154, upload-time = "2025-05-29T13:31:00.865Z" },
- { url = "https://files.pythonhosted.org/packages/4e/eb/3eae144c5114e92deb65a0cb2c72326c8469e14991e9bc3ec0349da1331c/ruff-0.11.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9b6886b524a1c659cee1758140138455d3c029783d1b9e643f3624a5ee0cb0aa", size = 10403048, upload-time = "2025-05-29T13:31:03.413Z" },
- { url = "https://files.pythonhosted.org/packages/29/64/20c54b20e58b1058db6689e94731f2a22e9f7abab74e1a758dfba058b6ca/ruff-0.11.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc3a3690aad6e86c1958d3ec3c38c4594b6ecec75c1f531e84160bd827b2012", size = 10597062, upload-time = "2025-05-29T13:31:05.539Z" },
- { url = "https://files.pythonhosted.org/packages/29/3a/79fa6a9a39422a400564ca7233a689a151f1039110f0bbbabcb38106883a/ruff-0.11.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f97fdbc2549f456c65b3b0048560d44ddd540db1f27c778a938371424b49fe4a", size = 10155152, upload-time = "2025-05-29T13:31:07.986Z" },
- { url = "https://files.pythonhosted.org/packages/e5/a4/22c2c97b2340aa968af3a39bc38045e78d36abd4ed3fa2bde91c31e712e3/ruff-0.11.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74adf84960236961090e2d1348c1a67d940fd12e811a33fb3d107df61eef8fc7", size = 11723067, upload-time = "2025-05-29T13:31:10.57Z" },
- { url = "https://files.pythonhosted.org/packages/bc/cf/3e452fbd9597bcd8058856ecd42b22751749d07935793a1856d988154151/ruff-0.11.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b56697e5b8bcf1d61293ccfe63873aba08fdbcbbba839fc046ec5926bdb25a3a", size = 12460807, upload-time = "2025-05-29T13:31:12.88Z" },
- { url = "https://files.pythonhosted.org/packages/2f/ec/8f170381a15e1eb7d93cb4feef8d17334d5a1eb33fee273aee5d1f8241a3/ruff-0.11.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d47afa45e7b0eaf5e5969c6b39cbd108be83910b5c74626247e366fd7a36a13", size = 12063261, upload-time = "2025-05-29T13:31:15.236Z" },
- { url = "https://files.pythonhosted.org/packages/0d/bf/57208f8c0a8153a14652a85f4116c0002148e83770d7a41f2e90b52d2b4e/ruff-0.11.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bf9603fe1bf949de8b09a2da896f05c01ed7a187f4a386cdba6760e7f61be", size = 11329601, upload-time = "2025-05-29T13:31:18.68Z" },
- { url = "https://files.pythonhosted.org/packages/c3/56/edf942f7fdac5888094d9ffa303f12096f1a93eb46570bcf5f14c0c70880/ruff-0.11.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08033320e979df3b20dba567c62f69c45e01df708b0f9c83912d7abd3e0801cd", size = 11522186, upload-time = "2025-05-29T13:31:21.216Z" },
- { url = "https://files.pythonhosted.org/packages/ed/63/79ffef65246911ed7e2290aeece48739d9603b3a35f9529fec0fc6c26400/ruff-0.11.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:929b7706584f5bfd61d67d5070f399057d07c70585fa8c4491d78ada452d3bef", size = 10449032, upload-time = "2025-05-29T13:31:23.417Z" },
- { url = "https://files.pythonhosted.org/packages/88/19/8c9d4d8a1c2a3f5a1ea45a64b42593d50e28b8e038f1aafd65d6b43647f3/ruff-0.11.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7de4a73205dc5756b8e09ee3ed67c38312dce1aa28972b93150f5751199981b5", size = 10129370, upload-time = "2025-05-29T13:31:25.777Z" },
- { url = "https://files.pythonhosted.org/packages/bc/0f/2d15533eaa18f460530a857e1778900cd867ded67f16c85723569d54e410/ruff-0.11.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2635c2a90ac1b8ca9e93b70af59dfd1dd2026a40e2d6eebaa3efb0465dd9cf02", size = 11123529, upload-time = "2025-05-29T13:31:28.396Z" },
- { url = "https://files.pythonhosted.org/packages/4f/e2/4c2ac669534bdded835356813f48ea33cfb3a947dc47f270038364587088/ruff-0.11.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d05d6a78a89166f03f03a198ecc9d18779076ad0eec476819467acb401028c0c", size = 11577642, upload-time = "2025-05-29T13:31:30.647Z" },
- { url = "https://files.pythonhosted.org/packages/a7/9b/c9ddf7f924d5617a1c94a93ba595f4b24cb5bc50e98b94433ab3f7ad27e5/ruff-0.11.12-py3-none-win32.whl", hash = "sha256:f5a07f49767c4be4772d161bfc049c1f242db0cfe1bd976e0f0886732a4765d6", size = 10475511, upload-time = "2025-05-29T13:31:32.917Z" },
- { url = "https://files.pythonhosted.org/packages/fd/d6/74fb6d3470c1aada019ffff33c0f9210af746cca0a4de19a1f10ce54968a/ruff-0.11.12-py3-none-win_amd64.whl", hash = "sha256:5a4d9f8030d8c3a45df201d7fb3ed38d0219bccd7955268e863ee4a115fa0832", size = 11523573, upload-time = "2025-05-29T13:31:35.782Z" },
- { url = "https://files.pythonhosted.org/packages/44/42/d58086ec20f52d2b0140752ae54b355ea2be2ed46f914231136dd1effcc7/ruff-0.11.12-py3-none-win_arm64.whl", hash = "sha256:65194e37853158d368e333ba282217941029a28ea90913c67e558c611d04daa5", size = 10697770, upload-time = "2025-05-29T13:31:38.009Z" },
+version = "0.14.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/55/cccfca45157a2031dcbb5a462a67f7cf27f8b37d4b3b1cd7438f0f5c1df6/ruff-0.14.4.tar.gz", hash = "sha256:f459a49fe1085a749f15414ca76f61595f1a2cc8778ed7c279b6ca2e1fd19df3", size = 5587844, upload-time = "2025-11-06T22:07:45.033Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/17/b9/67240254166ae1eaa38dec32265e9153ac53645a6c6670ed36ad00722af8/ruff-0.14.4-py3-none-linux_armv6l.whl", hash = "sha256:e6604613ffbcf2297cd5dcba0e0ac9bd0c11dc026442dfbb614504e87c349518", size = 12606781, upload-time = "2025-11-06T22:07:01.841Z" },
+ { url = "https://files.pythonhosted.org/packages/46/c8/09b3ab245d8652eafe5256ab59718641429f68681ee713ff06c5c549f156/ruff-0.14.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d99c0b52b6f0598acede45ee78288e5e9b4409d1ce7f661f0fa36d4cbeadf9a4", size = 12946765, upload-time = "2025-11-06T22:07:05.858Z" },
+ { url = "https://files.pythonhosted.org/packages/14/bb/1564b000219144bf5eed2359edc94c3590dd49d510751dad26202c18a17d/ruff-0.14.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9358d490ec030f1b51d048a7fd6ead418ed0826daf6149e95e30aa67c168af33", size = 11928120, upload-time = "2025-11-06T22:07:08.023Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/92/d5f1770e9988cc0742fefaa351e840d9aef04ec24ae1be36f333f96d5704/ruff-0.14.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b40d27924f1f02dfa827b9c0712a13c0e4b108421665322218fc38caf615c2", size = 12370877, upload-time = "2025-11-06T22:07:10.015Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/29/e9282efa55f1973d109faf839a63235575519c8ad278cc87a182a366810e/ruff-0.14.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f5e649052a294fe00818650712083cddc6cc02744afaf37202c65df9ea52efa5", size = 12408538, upload-time = "2025-11-06T22:07:13.085Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/01/930ed6ecfce130144b32d77d8d69f5c610e6d23e6857927150adf5d7379a/ruff-0.14.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa082a8f878deeba955531f975881828fd6afd90dfa757c2b0808aadb437136e", size = 13141942, upload-time = "2025-11-06T22:07:15.386Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/46/a9c89b42b231a9f487233f17a89cbef9d5acd538d9488687a02ad288fa6b/ruff-0.14.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1043c6811c2419e39011890f14d0a30470f19d47d197c4858b2787dfa698f6c8", size = 14544306, upload-time = "2025-11-06T22:07:17.631Z" },
+ { url = "https://files.pythonhosted.org/packages/78/96/9c6cf86491f2a6d52758b830b89b78c2ae61e8ca66b86bf5a20af73d20e6/ruff-0.14.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f3a936ac27fb7c2a93e4f4b943a662775879ac579a433291a6f69428722649", size = 14210427, upload-time = "2025-11-06T22:07:19.832Z" },
+ { url = "https://files.pythonhosted.org/packages/71/f4/0666fe7769a54f63e66404e8ff698de1dcde733e12e2fd1c9c6efb689cb5/ruff-0.14.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95643ffd209ce78bc113266b88fba3d39e0461f0cbc8b55fb92505030fb4a850", size = 13658488, upload-time = "2025-11-06T22:07:22.32Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/79/6ad4dda2cfd55e41ac9ed6d73ef9ab9475b1eef69f3a85957210c74ba12c/ruff-0.14.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456daa2fa1021bc86ca857f43fe29d5d8b3f0e55e9f90c58c317c1dcc2afc7b5", size = 13354908, upload-time = "2025-11-06T22:07:24.347Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/60/f0b6990f740bb15c1588601d19d21bcc1bd5de4330a07222041678a8e04f/ruff-0.14.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f911bba769e4a9f51af6e70037bb72b70b45a16db5ce73e1f72aefe6f6d62132", size = 13587803, upload-time = "2025-11-06T22:07:26.327Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/da/eaaada586f80068728338e0ef7f29ab3e4a08a692f92eb901a4f06bbff24/ruff-0.14.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:76158a7369b3979fa878612c623a7e5430c18b2fd1c73b214945c2d06337db67", size = 12279654, upload-time = "2025-11-06T22:07:28.46Z" },
+ { url = "https://files.pythonhosted.org/packages/66/d4/b1d0e82cf9bf8aed10a6d45be47b3f402730aa2c438164424783ac88c0ed/ruff-0.14.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f3b8f3b442d2b14c246e7aeca2e75915159e06a3540e2f4bed9f50d062d24469", size = 12357520, upload-time = "2025-11-06T22:07:31.468Z" },
+ { url = "https://files.pythonhosted.org/packages/04/f4/53e2b42cc82804617e5c7950b7079d79996c27e99c4652131c6a1100657f/ruff-0.14.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c62da9a06779deecf4d17ed04939ae8b31b517643b26370c3be1d26f3ef7dbde", size = 12719431, upload-time = "2025-11-06T22:07:33.831Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/94/80e3d74ed9a72d64e94a7b7706b1c1ebaa315ef2076fd33581f6a1cd2f95/ruff-0.14.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a443a83a1506c684e98acb8cb55abaf3ef725078be40237463dae4463366349", size = 13464394, upload-time = "2025-11-06T22:07:35.905Z" },
+ { url = "https://files.pythonhosted.org/packages/54/1a/a49f071f04c42345c793d22f6cf5e0920095e286119ee53a64a3a3004825/ruff-0.14.4-py3-none-win32.whl", hash = "sha256:643b69cb63cd996f1fc7229da726d07ac307eae442dd8974dbc7cf22c1e18fff", size = 12493429, upload-time = "2025-11-06T22:07:38.43Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/22/e58c43e641145a2b670328fb98bc384e20679b5774258b1e540207580266/ruff-0.14.4-py3-none-win_amd64.whl", hash = "sha256:26673da283b96fe35fa0c939bf8411abec47111644aa9f7cfbd3c573fb125d2c", size = 13635380, upload-time = "2025-11-06T22:07:40.496Z" },
+ { url = "https://files.pythonhosted.org/packages/30/bd/4168a751ddbbf43e86544b4de8b5c3b7be8d7167a2a5cb977d274e04f0a1/ruff-0.14.4-py3-none-win_arm64.whl", hash = "sha256:dd09c292479596b0e6fec8cd95c65c3a6dc68e9ad17b8f2382130f87ff6a75bb", size = 12663065, upload-time = "2025-11-06T22:07:42.603Z" },
]
[[package]]
name = "sentinels"
-version = "1.0.0"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/9b/07195878aa25fe6ed209ec74bc55ae3e3d263b60a489c6e73fdca3c8fe05/sentinels-1.1.1.tar.gz", hash = "sha256:3c2f64f754187c19e0a1a029b148b74cf58dd12ec27b4e19c0e5d6e22b5a9a86", size = 4393, upload-time = "2025-08-12T07:57:50.26Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/65/dea992c6a97074f6d8ff9eab34741298cac2ce23e2b6c74fb7d08afdf85c/sentinels-1.1.1-py3-none-any.whl", hash = "sha256:835d3b28f3b47f5284afa4bf2db6e00f2dc5f80f9923d4b7e7aeeeccf6146a11", size = 3744, upload-time = "2025-08-12T07:57:48.858Z" },
+]
+
+[[package]]
+name = "sentry-sdk"
+version = "2.44.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ac/b7/1af07a98390aba07da31807f3723e7bbd003d6441b4b3d67b20d97702b23/sentinels-1.0.0.tar.gz", hash = "sha256:7be0704d7fe1925e397e92d18669ace2f619c92b5d4eb21a89f31e026f9ff4b1", size = 4074, upload-time = "2016-08-30T07:19:19.963Z" }
+dependencies = [
+ { name = "certifi" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/62/26/ff7d93a14a0ec309021dca2fb7c62669d4f6f5654aa1baf60797a16681e0/sentry_sdk-2.44.0.tar.gz", hash = "sha256:5b1fe54dfafa332e900b07dd8f4dfe35753b64e78e7d9b1655a28fd3065e2493", size = 371464, upload-time = "2025-11-11T09:35:56.075Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a8/56/c16bda4d53012c71fa1b588edde603c6b455bc8206bf6de7b83388fcce75/sentry_sdk-2.44.0-py2.py3-none-any.whl", hash = "sha256:9e36a0372b881e8f92fdbff4564764ce6cec4b7f25424d0a3a8d609c9e4651a7", size = 402352, upload-time = "2025-11-11T09:35:54.1Z" },
+]
+
+[package.optional-dependencies]
+flask = [
+ { name = "blinker" },
+ { name = "flask" },
+ { name = "markupsafe" },
+]
[[package]]
name = "spoo-me"
version = "1.0.0"
source = { virtual = "." }
dependencies = [
+ { name = "argon2-cffi" },
+ { name = "authlib" },
{ name = "crawlerdetect" },
{ name = "dicttoxml" },
{ name = "emoji" },
{ name = "flask" },
{ name = "flask-caching" },
{ name = "flask-cors" },
- { name = "flask-limiter", extra = ["mongodb"] },
+ { name = "flask-limiter", version = "3.11.0", source = { registry = "https://pypi.org/simple" }, extra = ["mongodb"], marker = "python_full_version < '3.10'" },
+ { name = "flask-limiter", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["mongodb"], marker = "python_full_version >= '3.10'" },
{ name = "geoip2" },
{ name = "gunicorn" },
{ name = "openpyxl" },
{ name = "pycountry" },
+ { name = "pyjwt", extra = ["crypto"] },
{ name = "pymongo" },
{ name = "python-dotenv" },
{ name = "redis" },
{ name = "requests" },
+ { name = "sentry-sdk", extra = ["flask"] },
+ { name = "structlog" },
{ name = "tldextract" },
{ name = "ua-parser", extra = ["regex"] },
{ name = "validators" },
@@ -1343,7 +2031,8 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "mongomock" },
- { name = "pytest" },
+ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "pytest", version = "9.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "pytest-flask" },
{ name = "pytest-mock" },
{ name = "requests-mock" },
@@ -1353,6 +2042,8 @@ dev = [
[package.metadata]
requires-dist = [
+ { name = "argon2-cffi", specifier = ">=25.1.0" },
+ { name = "authlib", specifier = ">=1.6.5" },
{ name = "crawlerdetect", specifier = ">=0.3.0" },
{ name = "dicttoxml", specifier = ">=1.7.16" },
{ name = "emoji", specifier = ">=2.14.1" },
@@ -1364,10 +2055,13 @@ requires-dist = [
{ name = "gunicorn", specifier = ">=23.0.0" },
{ name = "openpyxl", specifier = ">=3.1.5" },
{ name = "pycountry", specifier = ">=24.6.1" },
+ { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" },
{ name = "pymongo", specifier = ">=4.13.0" },
{ name = "python-dotenv", specifier = ">=1.1.0" },
{ name = "redis", specifier = ">=6.2.0" },
{ name = "requests", specifier = ">=2.32.3" },
+ { name = "sentry-sdk", extras = ["flask"], specifier = ">=2.44.0" },
+ { name = "structlog", specifier = ">=25.5.0" },
{ name = "tldextract", specifier = ">=5.3.0" },
{ name = "ua-parser", extras = ["regex"], specifier = ">=1.0.1" },
{ name = "validators", specifier = ">=0.35.0" },
@@ -1381,7 +2075,19 @@ dev = [
{ name = "pytest-mock", specifier = ">=3.14.1" },
{ name = "requests-mock", specifier = ">=1.12.1" },
{ name = "ruff", specifier = ">=0.11.11" },
- { name = "uv", specifier = ">=0.7.8" },
+ { name = "uv", specifier = ">=0.9.6" },
+]
+
+[[package]]
+name = "structlog"
+version = "25.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" },
]
[[package]]
@@ -1389,7 +2095,8 @@ name = "tldextract"
version = "5.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "filelock" },
+ { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "filelock", version = "3.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "idna" },
{ name = "requests" },
{ name = "requests-file" },
@@ -1401,50 +2108,60 @@ wheels = [
[[package]]
name = "tomli"
-version = "2.2.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" },
- { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" },
- { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" },
- { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" },
- { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" },
- { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" },
- { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" },
- { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" },
- { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" },
- { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" },
- { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" },
- { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" },
- { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" },
- { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" },
- { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" },
- { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" },
- { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" },
- { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" },
- { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" },
- { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" },
- { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" },
- { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" },
- { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" },
- { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" },
- { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" },
- { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" },
- { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" },
- { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" },
- { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" },
- { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" },
- { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" },
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" },
+ { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" },
+ { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" },
+ { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" },
+ { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" },
+ { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" },
+ { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" },
+ { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" },
+ { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" },
+ { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" },
+ { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" },
+ { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" },
+ { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" },
+ { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" },
+ { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" },
+ { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" },
+ { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" },
+ { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" },
+ { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" },
+ { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" },
+ { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" },
+ { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" },
+ { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" },
+ { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" },
+ { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" },
+ { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" },
+ { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" },
]
[[package]]
name = "typing-extensions"
-version = "4.13.2"
+version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" },
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
@@ -1474,60 +2191,73 @@ wheels = [
[[package]]
name = "ua-parser-rs"
-version = "0.1.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a5/8b/6a3322c792b379b6928f881fe04e1353b98bcaec290352ce6ddfeff020e4/ua_parser_rs-0.1.2.tar.gz", hash = "sha256:8da38878f81c4d15b3a4815327e81117aa91c1efc148a74f0cc18dfada98fdbc", size = 762645, upload-time = "2024-11-24T21:00:10.799Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ad/fd/089dec30b65da91b69f6684659e869799c67d102a9f11285696af35f887e/ua_parser_rs-0.1.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:3f7393faff686d80acba6d8952878e1a36c8f9e6db06863c7bd30b73bcb7c5e2", size = 1027451, upload-time = "2024-11-24T20:59:59.536Z" },
- { url = "https://files.pythonhosted.org/packages/62/1f/14e2211620c86ad05dcdfc48f12d498625609fba2c7496167b5833286f81/ua_parser_rs-0.1.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:25504abadef8c741c7730c4505f7983b84a156370ab2dc80170f89cd056b4dcb", size = 968417, upload-time = "2024-11-24T20:59:54.533Z" },
- { url = "https://files.pythonhosted.org/packages/93/8c/be59ce5516a39f37ed5bfa044162edbbd9e9031889ece8849bc729feb91c/ua_parser_rs-0.1.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bfd3a78b0cf4cf842c76c1135f3a5a91807cac218a7a5b0bfcb936388d0ee91", size = 7878169, upload-time = "2024-11-24T20:59:40.278Z" },
- { url = "https://files.pythonhosted.org/packages/00/74/f1cb5c2c4b2df2eb7b4e7a8e3a8a302e9e9a5b2e5a13082620c2c1b60abe/ua_parser_rs-0.1.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4e193a2f9dd4c96674abd9ff15dd4483a4e09262937a0e5ee9569eec4646ba4", size = 8274614, upload-time = "2024-11-24T20:59:47.449Z" },
- { url = "https://files.pythonhosted.org/packages/61/30/4ce95582bcc5bf7cbaeba5b60e88b32d88b578a27d13c316eaf25befd37a/ua_parser_rs-0.1.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:862ecf7eac9ebdf266eac6c684ecef74c60cb0636d4f894f80e6c380aece02de", size = 7509317, upload-time = "2024-11-24T21:00:05.334Z" },
- { url = "https://files.pythonhosted.org/packages/73/ee/1085f5b0d075378a329bda04c73ec345235d8e43a512f703a8f84a14d9f5/ua_parser_rs-0.1.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c5e93f491f5ea909755a1ecb57e33ca766799f2bb50ef1dcfa504db1e45f1d0b", size = 7918569, upload-time = "2024-11-24T21:00:08.22Z" },
- { url = "https://files.pythonhosted.org/packages/b5/10/1107ba0e6c0089c9ef8b01b55279dbcef6c07923a1e4ebb2b0fdd41a8232/ua_parser_rs-0.1.2-cp39-abi3-win_amd64.whl", hash = "sha256:cd7b789070af0572f3aa7ff13cb2d1900e8cff44f4ec406063a38a0a75af11dc", size = 816964, upload-time = "2024-11-24T21:00:11.959Z" },
- { url = "https://files.pythonhosted.org/packages/e2/e7/259c65a4445a693b1e6d46256780166fecd3473ed72b00f1582ea2600dce/ua_parser_rs-0.1.2-graalpy311-graalpy241_311_native-macosx_10_12_x86_64.whl", hash = "sha256:0a745ed8c075f33c18d0ae109cc35558354570337a47f0bf7cb094c9c084e3c6", size = 1024934, upload-time = "2024-11-24T21:00:01.149Z" },
- { url = "https://files.pythonhosted.org/packages/c2/54/755360fec722f49e49ec0599845ab9f594c7071f5e76ce77d4333a2ae82a/ua_parser_rs-0.1.2-graalpy311-graalpy241_311_native-macosx_11_0_arm64.whl", hash = "sha256:d5c56f4597bd7d9e35f5dcf8ab66fece07938388883afaf265457c04d35ecdb8", size = 966478, upload-time = "2024-11-24T20:59:56.529Z" },
- { url = "https://files.pythonhosted.org/packages/c1/9e/84f347ffdc1b03f62fff0cd65b0ffa3a0fba22c45e2f4f0ed4b4bc36dfab/ua_parser_rs-0.1.2-graalpy311-graalpy241_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6de1d44ae2a20dff2d7b7a5eb8ffb667f8255b318e407ea5704f522cfca60b04", size = 7878566, upload-time = "2024-11-24T20:59:43.265Z" },
- { url = "https://files.pythonhosted.org/packages/31/ff/f0b2375e2af1c8a81ed3ebbe2976d4805e093eafbd80ffacac08a4b8a176/ua_parser_rs-0.1.2-graalpy311-graalpy241_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac101d7c85bb157c682f98374eafde80251aed91f7fb63c58b035deacf9e268f", size = 8248258, upload-time = "2024-11-24T20:59:49.646Z" },
- { url = "https://files.pythonhosted.org/packages/ea/1f/1c56530761656ae9a3084e9457e68c7136b85b451b39411fdb90ee1c608f/ua_parser_rs-0.1.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9bd065518cdb42aba3b9a3851806b3d46b6b1185c17682420e0a9758815fe388", size = 1025631, upload-time = "2024-11-24T21:00:03.23Z" },
- { url = "https://files.pythonhosted.org/packages/e3/34/06ca71028f4d865483fb4d3536537445f7626eef0971ef3218aac2f62fe4/ua_parser_rs-0.1.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:601e858188834aa243abd6a3247c64da80c1437b8d9fe5eec5632ae64691794b", size = 967316, upload-time = "2024-11-24T20:59:58.18Z" },
- { url = "https://files.pythonhosted.org/packages/44/66/0348c45e5914910dfda5a3cfa51ccdc4785d1f7b0d5a363f006d2c0e0af7/ua_parser_rs-0.1.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7591d3b905cbe0661c6974a124f728e4fa1bfc4f7f082078cd27e885d8f94f2a", size = 7935000, upload-time = "2024-11-24T20:59:45.128Z" },
- { url = "https://files.pythonhosted.org/packages/c1/fc/5388c0d1a239f5986b7ce7c8a9c2416df425c6f78fd9f8eafdedc3f093a4/ua_parser_rs-0.1.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a47b02c7058c682b4a326f62bf12fdcc411b03db984c2292b4d57c39d9e5c43", size = 8241719, upload-time = "2024-11-24T20:59:52.135Z" },
- { url = "https://files.pythonhosted.org/packages/6b/20/41bbadafd10e16708c51dfc70ffbbc9b0942bf32b4fed3cdbb10a83d5bcc/ua_parser_rs-0.1.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a97927c72241d4dc8750be9dd9c73ce4522a4b06c2298f06f486d4eb0bbf375b", size = 813186, upload-time = "2024-11-24T21:00:13.345Z" },
+version = "0.1.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3d/ca/c1edc09daf3858cc529db4d4c3c45b3b6c35e3f179ba933419f790316dff/ua_parser_rs-0.1.3.tar.gz", hash = "sha256:afd40e64ffce5f629bc09d415100d409455af5f6439cac0278d6bf03c46d3262", size = 762609, upload-time = "2025-06-15T19:40:32.365Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d5/85/ceba26e17811b7b7c182abff4b6072dc42b2d4bc159cb5cb418eaaadfee5/ua_parser_rs-0.1.3-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:baa85a995eb34d53802036b0a09ce1afb290be87f78948d91cbc2ed79b48336c", size = 1023991, upload-time = "2025-06-15T19:40:09.393Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/06/bbd55c7ef798d32ceea0829bb83d601512fcdc8dda5675f7e822493424c0/ua_parser_rs-0.1.3-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:271372f504cab7850262c12db2eba70bc367abf728e08acae1495c048d947fff", size = 964578, upload-time = "2025-06-15T19:40:03.19Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/b7/c81a46d252518fcb6e15bb70fe41a3eacbb6cd038fe1213c083e14be2957/ua_parser_rs-0.1.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc34e3cd4b5f8675475c968788c1c4ce7f2e0d1457a1ba5582a2e9e6e2889789", size = 7683181, upload-time = "2025-06-15T19:39:45.928Z" },
+ { url = "https://files.pythonhosted.org/packages/be/74/187b161e57931a28bcc7dbf7bd04dfc15f60647f908ae19aaf9ab40a8b8b/ua_parser_rs-0.1.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afbeafa81f62308ea86b74c71aece44fa232af8b012a847e39adfd92903e1bc2", size = 8023732, upload-time = "2025-06-15T19:39:54.573Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/26/937c7b7172d412efc97c0c5bd3861a7b82092d7eab4943ed78441c1344ad/ua_parser_rs-0.1.3-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f7f1b3d3efb59a6eac60f9651a7e1450ff9ca8c078a838bf00afa68aeea658f", size = 7149095, upload-time = "2025-06-15T19:40:16.401Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/43/5f3e38195848772f87e22f9e15ea4263dcd63c571b9c176a8407ff228a4c/ua_parser_rs-0.1.3-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a9e5ead1f8e1399ca99e3a5c8e402f6c7b563f0af3e6009188593082ebc52eb5", size = 7500059, upload-time = "2025-06-15T19:40:24.121Z" },
+ { url = "https://files.pythonhosted.org/packages/22/1e/fdc49dde42bdd202c724e98a31755184f5ebdd7ca355e2253a4f0daff3a2/ua_parser_rs-0.1.3-cp39-abi3-win_amd64.whl", hash = "sha256:730d416e30e57cc6295963cf093220100e6a64acc2105eb860aa174633ad3b13", size = 803071, upload-time = "2025-06-15T19:40:35.557Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/c7/00416794e68d58846a7ee963a0ec28e83efee257dd6bf6f92a5e354055bb/ua_parser_rs-0.1.3-cp39-abi3-win_arm64.whl", hash = "sha256:ae8e3825b729e89f3ec5c213b9a5bdfadc6461bad3b4ac5bd2187aea7ad78595", size = 739442, upload-time = "2025-06-15T19:40:34.024Z" },
+ { url = "https://files.pythonhosted.org/packages/78/69/b94f619613df7933e51b86b801cfd9c7c86173179f6033c20ef7dcf94515/ua_parser_rs-0.1.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b624a5b1c0afa9e218838fc19135d69bd127a8b9b4e3cdd304ab47ce6435328c", size = 1022148, upload-time = "2025-06-15T19:40:10.913Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/1f/d5fa92d3db72167e1f2ab90af5cbb67eedc82de2525e7eac1598fe20c515/ua_parser_rs-0.1.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:b0234c9645cb60de024035470e7d9b4a946d289d9d14c6c2ad72011113b7487f", size = 961362, upload-time = "2025-06-15T19:40:04.807Z" },
+ { url = "https://files.pythonhosted.org/packages/25/72/3675f8120b704752e1796aaa63ed9bf664ce002fcc8b4db2d2faf3d64333/ua_parser_rs-0.1.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10f8e13de9491db5527bcc68d94e01fe67d8b5412229416465dff7a3a82e9a50", size = 7673121, upload-time = "2025-06-15T19:39:48.255Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/56/dafa51e33091bb779ed1239e04d33d93371249697aa017461e4f477236ff/ua_parser_rs-0.1.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c56c2ed1380f7a551f50f16c69e968dda2e89a3ca5a4d5713890045bcc7a183", size = 8013867, upload-time = "2025-06-15T19:39:56.755Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/bd/2d2e3015d75bba21c47941012ebb919a07aa72ed0b2c7383e31d7409eda1/ua_parser_rs-0.1.3-graalpy311-graalpy242_311_native-manylinux_2_34_aarch64.whl", hash = "sha256:a52e0b121e43ddfc526f0667c3aa43c86801c3462fbf1cf1ae36eca8288268c0", size = 7141188, upload-time = "2025-06-15T19:40:18.488Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/8f/a820c118f3392ca04899f9162a7192f047c84c65d838580c01ada2810c18/ua_parser_rs-0.1.3-graalpy311-graalpy242_311_native-manylinux_2_34_x86_64.whl", hash = "sha256:bf70dd8142a8cd2e5b3724b2e98c9c1659ab2ec70febd0608d12fcd9a12eeb63", size = 7491864, upload-time = "2025-06-15T19:40:26.374Z" },
+ { url = "https://files.pythonhosted.org/packages/28/94/7588bb222c41e91bef741a4d3dad2434dffff9e0b9e7a69fd4b06612874f/ua_parser_rs-0.1.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:acd5db6ab642bdcf079bdf905c5ffa2ec971e154b5610ad48ca162b851fc4142", size = 1024233, upload-time = "2025-06-15T19:40:12.847Z" },
+ { url = "https://files.pythonhosted.org/packages/70/b7/791a225072cc2cfaa7146ae6cbb6b75c48b406d1269ed1b6527b3727702d/ua_parser_rs-0.1.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:dca20ec9f3edb272570e41be1a51d96f18e01c344b5dd50dcc7fef1b59102d87", size = 964398, upload-time = "2025-06-15T19:40:06.424Z" },
+ { url = "https://files.pythonhosted.org/packages/79/3b/408fc075bf86716ae1d1c4ec93e71d32b98a6ec6e5f2a3cd1d11760de54d/ua_parser_rs-0.1.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41c8866379172d6448642c9e64384ff90527856567ee73e10be949ff8dddfb30", size = 7677829, upload-time = "2025-06-15T19:39:50.133Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/4c/c2d84329ce3b29a45e8dce254144fd201737c7ed3d435add2d144662c372/ua_parser_rs-0.1.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84f79528a9cbb03557b7f53248cef12b82935a9a62142aeda19231828fa040c", size = 8018021, upload-time = "2025-06-15T19:39:59.089Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/ec/422f95c5850ce2ec0469567b58de3dc1e484cf06c23652b3cdcb4cc54078/ua_parser_rs-0.1.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:4a86c662fef8d720d180c910fd1d4d1cab5d6ff3611fe36039538bc1e43b26a9", size = 7146987, upload-time = "2025-06-15T19:40:20.32Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/8f/38f548de0493cd417fb614725c5375fe9b9d59f4f355d18ab03c966ee4dd/ua_parser_rs-0.1.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:61ea14de0a1ef9492201fd530540151d6b7d29f4f2cc1c9a637d86825877770a", size = 7494126, upload-time = "2025-06-15T19:40:28.365Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/99/dea9f41c2dfd39884fe5cf27c2b6063b358c22d350cfb6711b5179506807/ua_parser_rs-0.1.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:915b5b53b25dc8827bbc0c83f1784430115e024504951e75f8a9a53d6f7b2917", size = 801104, upload-time = "2025-06-15T19:40:37.652Z" },
+ { url = "https://files.pythonhosted.org/packages/91/87/1165fc514fa6eca4578553792f6cdd5592cb106a8bd304a66812b4ec74b7/ua_parser_rs-0.1.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:668071f010bc47bd88c6f08624268732fe53c926d71e4bf3ad6ef7f0b582481f", size = 1020952, upload-time = "2025-06-15T19:40:14.346Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/e2/64441607bed19d8c4b458bb1950d28ba7d7b58f1c393e16d8a3e9a1c412d/ua_parser_rs-0.1.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f01ad6fb97a888a350bd977a7c166682c0ad9a888dcdf12cbdf3acb631309475", size = 960733, upload-time = "2025-06-15T19:40:07.879Z" },
+ { url = "https://files.pythonhosted.org/packages/71/26/19b975e4b0426cf2eb7d56534022f6292ae4cae43a682e0feff30307c638/ua_parser_rs-0.1.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce7acabdb2e4f5b517bc8dc56ca95a9522281e493db1b3e091b5bc78fa8bccf2", size = 7668086, upload-time = "2025-06-15T19:39:52.301Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/dd/502b0c87b288fdffc049449272fcd75d0e32e7b2971324ae60147a151e08/ua_parser_rs-0.1.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d2e03972990e26f542d7b5c386785697d4f5591f79cf35a31a504927b0679a4", size = 8015316, upload-time = "2025-06-15T19:40:01.072Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/d4/cee61a30d35a1065a32ba847946d91d862c898438a0ff1fc6aaee9f918a5/ua_parser_rs-0.1.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:4d0d82d4a42796ec6efe6fed58b0ac876211e4049f59cb905d0b7137284a9bc1", size = 7136747, upload-time = "2025-06-15T19:40:22.255Z" },
+ { url = "https://files.pythonhosted.org/packages/17/59/55f828466f383ac06ca613301822b02a02066dc3d5ebaac47ee4bc1e4914/ua_parser_rs-0.1.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:e3e232268b181b5d27c8d9bacedec881b5e02bd6651cb617968ec70abaa73d16", size = 7495267, upload-time = "2025-06-15T19:40:30.589Z" },
+ { url = "https://files.pythonhosted.org/packages/16/d4/201e95b6b3de93e7c0183421ac71b7c514dd9944094f78b4504240439d55/ua_parser_rs-0.1.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:74f98b200ca4c6db2eec450f66fc1d95ec82516ab604e2bcd774d2f5444db75c", size = 798242, upload-time = "2025-06-15T19:40:39.072Z" },
]
[[package]]
name = "urllib3"
-version = "2.4.0"
+version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "uv"
-version = "0.7.8"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0c/4f/c26b354fc791fb716a990f6b0147c0b5d69351400030654827fb920fd79b/uv-0.7.8.tar.gz", hash = "sha256:a59d6749587946d63d371170d8f69d168ca8f4eade5cf880ad3be2793ea29c77", size = 3258494, upload-time = "2025-05-24T00:28:18.241Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/db/48/dd73c6a9b7b18dc1784b243cd5a93c14db34876c5a5cbb215e00be285e05/uv-0.7.8-py3-none-linux_armv6l.whl", hash = "sha256:ff1b7e4bc8a1d260062782ad34d12ce0df068df01d4a0f61d0ddc20aba1a5688", size = 16741809, upload-time = "2025-05-24T00:27:20.873Z" },
- { url = "https://files.pythonhosted.org/packages/b4/bd/0bc26f1f4f476cff93c8ce2d258819b10b9a4e41a9825405788ef25a2300/uv-0.7.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b83866be6a69f680f3d2e36b3befd2661b5596e59e575e266e7446b28efa8319", size = 16836506, upload-time = "2025-05-24T00:27:25.229Z" },
- { url = "https://files.pythonhosted.org/packages/26/28/1573e22b5f109f7779ddf64cb11e8e475ac05cf94e6b79ad3a4494c8c39c/uv-0.7.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f749b58a5c348c455083781c92910e49b4ddba85c591eb67e97a8b84db03ef9b", size = 15642479, upload-time = "2025-05-24T00:27:28.866Z" },
- { url = "https://files.pythonhosted.org/packages/ad/f1/3d403896ea1edeea9109cab924e6a724ed7f5fbdabe8e5e9f3e3aa2be95a/uv-0.7.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c058ee0f8c20b0942bd9f5c83a67b46577fa79f5691df8867b8e0f2d74cbadb1", size = 16043352, upload-time = "2025-05-24T00:27:31.911Z" },
- { url = "https://files.pythonhosted.org/packages/c7/2e/a914e491af320be503db26ff57f1b328738d1d7419cdb690e6e31d87ae16/uv-0.7.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2a07bdf9d6aadef40dd4edbe209bca698a3d3244df5285d40d2125f82455519c", size = 16413446, upload-time = "2025-05-24T00:27:35.363Z" },
- { url = "https://files.pythonhosted.org/packages/c3/cc/a396870530db7661eac080d276eba25df1b6c930f50c721f8402370acd12/uv-0.7.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13af6b94563f25bdca6bb73e294648af9c0b165af5bb60f0c913ab125ec45e06", size = 17188599, upload-time = "2025-05-24T00:27:38.979Z" },
- { url = "https://files.pythonhosted.org/packages/d0/96/299bd3895d630e28593dcc54f4c4dbd72e12b557288c6d153987bbd62f34/uv-0.7.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4acc09c06d6cf7a27e0f1de4edb8c1698b8a3ffe34f322b10f4c145989e434b9", size = 18105049, upload-time = "2025-05-24T00:27:42.194Z" },
- { url = "https://files.pythonhosted.org/packages/8f/a4/9fa0b6a4540950fe7fa66d37c44228d6ad7bb6d42f66e16f4f96e20fd50c/uv-0.7.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9221a9679f2ffd031b71b735b84f58d5a2f1adf9bfa59c8e82a5201dad7db466", size = 17777603, upload-time = "2025-05-24T00:27:45.695Z" },
- { url = "https://files.pythonhosted.org/packages/d7/62/988cca0f1723406ff22edd6a9fb5e3e1d4dd0af103d8c3a64effadc685fd/uv-0.7.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:409cee21edcaf4a7c714893656ab4dd0814a15659cb4b81c6929cbb75cd2d378", size = 22222113, upload-time = "2025-05-24T00:27:49.172Z" },
- { url = "https://files.pythonhosted.org/packages/06/36/0e7943d9415560aa9fdd775d0bb4b9c06b69c543f0647210e5b84776658b/uv-0.7.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81ac0bb371979f48d1293f9c1bee691680ea6a724f16880c8f76718f5ff50049", size = 17454597, upload-time = "2025-05-24T00:27:52.478Z" },
- { url = "https://files.pythonhosted.org/packages/bb/70/666be8dbc6a49e1a096f4577d69c4e6f78b3d9228fa2844d1bece21f5cd0/uv-0.7.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:3c620cecd6f3cdab59b316f41c2b1c4d1b709d9d5226cadeec370cfeed56f80c", size = 16335744, upload-time = "2025-05-24T00:27:55.657Z" },
- { url = "https://files.pythonhosted.org/packages/24/a5/c1fbffc8b62121c0d07aa66e7e5135065ff881ebb85ba307664125f4c51c/uv-0.7.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:0c691090ff631dde788c8f4f1b1ea20f9deb9d805289796dcf10bc4a144a817e", size = 16439468, upload-time = "2025-05-24T00:27:58.599Z" },
- { url = "https://files.pythonhosted.org/packages/65/95/a079658721b88d483c97a1765f9fd4f1b8b4fa601f2889d86824244861f2/uv-0.7.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:4a117fe3806ba4ebb9c68fdbf91507e515a883dfab73fa863df9bc617d6de7a3", size = 16740156, upload-time = "2025-05-24T00:28:01.657Z" },
- { url = "https://files.pythonhosted.org/packages/14/69/a2d110786c4cf093d788cfcde9e99c634af087555f0bf9ceafc009d051ed/uv-0.7.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:91d022235b39e59bab4bce7c4b634dc67e16fa89725cdfb2149a6ef7eaf6d784", size = 17569652, upload-time = "2025-05-24T00:28:04.903Z" },
- { url = "https://files.pythonhosted.org/packages/6f/56/db6db0dc20114b76eb48dbd5167a26a2ebe51e8b604b4e84c5ef84ef4103/uv-0.7.8-py3-none-win32.whl", hash = "sha256:6ebe252f34c50b09b7f641f8e603d7b627f579c76f181680c757012b808be456", size = 16958006, upload-time = "2025-05-24T00:28:07.996Z" },
- { url = "https://files.pythonhosted.org/packages/4b/80/5c78a9adc50fa3b7cca3a0c1245dff8c74d906ab53c3503b1f8133243930/uv-0.7.8-py3-none-win_amd64.whl", hash = "sha256:b5b62ca8a1bea5fdbf8a6372eabb03376dffddb5d139688bbb488c0719fa52fc", size = 18457129, upload-time = "2025-05-24T00:28:11.844Z" },
- { url = "https://files.pythonhosted.org/packages/15/52/fd76b44942ac308e1dbbebea8b23de67a0f891a54d5e51346c3c3564dd9b/uv-0.7.8-py3-none-win_arm64.whl", hash = "sha256:ad79388b0c6eff5383b963d8d5ddcb7fbb24b0b82bf5d0c8b1bdbfbe445cb868", size = 17177058, upload-time = "2025-05-24T00:28:15.561Z" },
+version = "0.9.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/78/291b32fdcc774b8ba4a0f4570af44af6cd34ef7385537d6521c7e3280030/uv-0.9.8.tar.gz", hash = "sha256:99b18bfe92c33c3862b65d74677697e799763e669e0064685f405e7e27517f25", size = 3709979, upload-time = "2025-11-07T20:41:33.748Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/11/5d/4db5a4e72f70e15491ca33289092cd127d1220861bc647ebf743ea844cd7/uv-0.9.8-py3-none-linux_armv6l.whl", hash = "sha256:d93a2227d23e81ab3a16c30363559afc483e8aca40ea9343b3f326a9a41718c9", size = 20566439, upload-time = "2025-11-07T20:40:26.268Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/76/3ffedb2ba3adf71719996cb4c2660a333d2267503823a02e184a839e1d4e/uv-0.9.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7038a552159f2291dd0d1f4f66a36261b5f3ed5fcd92e2869186f8e910b2c935", size = 19705224, upload-time = "2025-11-07T20:40:31.384Z" },
+ { url = "https://files.pythonhosted.org/packages/da/37/7716dd87189a6b062502ea41650eccd2473b6ee54b37cdf6e90a3b1aaa17/uv-0.9.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9f2f3576c4518ff4f15e48dbca70585a513523c4738bc8cc2e48b20fd1190ce3", size = 18213823, upload-time = "2025-11-07T20:40:34.962Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/ed/7aa302fac3d6c880df6bdbba3fb6b4d8cded023b1398f99576dcb103051a/uv-0.9.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:50d130c46d97d7f10675ebea8608b7b4722c84b5745cd1bb0c8ae6d7984c05d5", size = 20090145, upload-time = "2025-11-07T20:40:38.842Z" },
+ { url = "https://files.pythonhosted.org/packages/72/d2/2539fe7ecf03f5fa3dfcc4c39f59ade412bd1b8e89c9ae026b5a2d7da3dd/uv-0.9.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6df2e16f6df32018047c60bab2c0284868ad5c309addba9183ea2eeb71746bf0", size = 20218906, upload-time = "2025-11-07T20:40:42.189Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/29/2923cd822b9a1dc9b99513a00d2102c7ef979ac3001e9541e72a1e7fca07/uv-0.9.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:543693def38fa41b9706aba391111fe8d9dd6be86899d76f9581faf045ac1cb6", size = 21061669, upload-time = "2025-11-07T20:40:47.663Z" },
+ { url = "https://files.pythonhosted.org/packages/72/c6/46b9fe190e6fafb6bf04d870ccfd547e69aa79d0448a5c2c5799f1c0850e/uv-0.9.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1b8b5bdcda3e10ea70b618d0609acddc5c725cb58d4caf933030ddedd7c2e98f", size = 22668783, upload-time = "2025-11-07T20:40:51.172Z" },
+ { url = "https://files.pythonhosted.org/packages/94/80/ec48165c76f863bbfcb0721aa1543cd3e7209c0cb8fdf89fe3d4e16694e2/uv-0.9.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a4010b3fdabbb3c4f2cf2f7aa3bf6002d00049dcbc54ce0ee5ada32a933b2290", size = 22319178, upload-time = "2025-11-07T20:40:54.719Z" },
+ { url = "https://files.pythonhosted.org/packages/33/6c/2dbda528a2cd7a87a7363e8a9aad3033bff12c8b071a5e462eb852e704fd/uv-0.9.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75671150d6eb9d5ee829e1fdb8cf86b8e44a66d27cbb996fe807e986c4107b5d", size = 21398576, upload-time = "2025-11-07T20:40:58.509Z" },
+ { url = "https://files.pythonhosted.org/packages/90/66/07e7067ace0886212217380b6e809f7dd1fed3d35c34be8d02124a656b17/uv-0.9.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14670bf55ecb5cfd0f3654fbf51c58a21dec3ad8ab531079b3ed8599271dc77b", size = 21346696, upload-time = "2025-11-07T20:41:01.931Z" },
+ { url = "https://files.pythonhosted.org/packages/35/98/5b8fad804d17e76a2861c932009b0d34c7d5e3517923a808b168c2d92f2b/uv-0.9.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:40253d00c1e900a0a61b132b1e0dd4aa83575cfd5302d3671899b6de29b1ef67", size = 20159753, upload-time = "2025-11-07T20:41:05.51Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/e4/32b74e9246e71f27b8710ba44be6bfd8bdcf552dce211cecd4d1061705cc/uv-0.9.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f52c6a99197028a314d4c1825f7ccb696eb9a88b822d2e2f17046266c75e543e", size = 21299928, upload-time = "2025-11-07T20:41:09.285Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/35/003035bc2da31cc9925a62b1510a821d701c117cf0327ab0a1df5c83db34/uv-0.9.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:5af28f1645eb3c50fd34a78508792db2d0799816f4eb5f55e1e6e2c724dfb125", size = 20170593, upload-time = "2025-11-07T20:41:12.745Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/b4/8c3d7afdc87ef07b51b87646a4c75ee5209b7f9f99a33d54746b7ee0f157/uv-0.9.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:cdbfadca9522422ab9820f5ada071c9c5c869bcd6fee719d20d91d5ec85b2a7d", size = 20560556, upload-time = "2025-11-07T20:41:16.85Z" },
+ { url = "https://files.pythonhosted.org/packages/64/43/6045bb0b69c788620df4750de57319f56a9b5bd02eef56f28af0de25c117/uv-0.9.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:87c3b65b6d5fcbdeab199d54c74fbf75de19cb534a690c936c5616478a038576", size = 21530469, upload-time = "2025-11-07T20:41:20.336Z" },
+ { url = "https://files.pythonhosted.org/packages/96/a4/8bb8dca265df52abc405161f918225fbf156fc3a16f380a382a5cd52f992/uv-0.9.8-py3-none-win32.whl", hash = "sha256:0f03bc413c933dbf850ad0dc2dba3df6b80c860a5c65cd767add49da19dadef0", size = 19440191, upload-time = "2025-11-07T20:41:23.612Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/b6/9a2ed2c1cc86b967de82c20aeee2860f8771adbcf010061359f5406a6bed/uv-0.9.8-py3-none-win_amd64.whl", hash = "sha256:6a01d7cd41953ffac583139b10ad1df004a67c0246a6b694eb5bcdbc8c99deaf", size = 21491715, upload-time = "2025-11-07T20:41:27.181Z" },
+ { url = "https://files.pythonhosted.org/packages/95/77/4a8f429c8d89a17a5327e7be8a7f3b72f7422b0acccfc378d424ca6dc0c9/uv-0.9.8-py3-none-win_arm64.whl", hash = "sha256:bb0f8e83c2a2fc5a802e930cc8a7b71ab068180300a3f27ba38037f9fcb3d430", size = 19865491, upload-time = "2025-11-07T20:41:30.62Z" },
]
[[package]]
@@ -1553,200 +2283,256 @@ wheels = [
[[package]]
name = "wrapt"
-version = "1.17.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531, upload-time = "2025-01-14T10:35:45.465Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5a/d1/1daec934997e8b160040c78d7b31789f19b122110a75eca3d4e8da0049e1/wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984", size = 53307, upload-time = "2025-01-14T10:33:13.616Z" },
- { url = "https://files.pythonhosted.org/packages/1b/7b/13369d42651b809389c1a7153baa01d9700430576c81a2f5c5e460df0ed9/wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22", size = 38486, upload-time = "2025-01-14T10:33:15.947Z" },
- { url = "https://files.pythonhosted.org/packages/62/bf/e0105016f907c30b4bd9e377867c48c34dc9c6c0c104556c9c9126bd89ed/wrapt-1.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80dd7db6a7cb57ffbc279c4394246414ec99537ae81ffd702443335a61dbf3a7", size = 38777, upload-time = "2025-01-14T10:33:17.462Z" },
- { url = "https://files.pythonhosted.org/packages/27/70/0f6e0679845cbf8b165e027d43402a55494779295c4b08414097b258ac87/wrapt-1.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a6e821770cf99cc586d33833b2ff32faebdbe886bd6322395606cf55153246c", size = 83314, upload-time = "2025-01-14T10:33:21.282Z" },
- { url = "https://files.pythonhosted.org/packages/0f/77/0576d841bf84af8579124a93d216f55d6f74374e4445264cb378a6ed33eb/wrapt-1.17.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b60fb58b90c6d63779cb0c0c54eeb38941bae3ecf7a73c764c52c88c2dcb9d72", size = 74947, upload-time = "2025-01-14T10:33:24.414Z" },
- { url = "https://files.pythonhosted.org/packages/90/ec/00759565518f268ed707dcc40f7eeec38637d46b098a1f5143bff488fe97/wrapt-1.17.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b870b5df5b71d8c3359d21be8f0d6c485fa0ebdb6477dda51a1ea54a9b558061", size = 82778, upload-time = "2025-01-14T10:33:26.152Z" },
- { url = "https://files.pythonhosted.org/packages/f8/5a/7cffd26b1c607b0b0c8a9ca9d75757ad7620c9c0a9b4a25d3f8a1480fafc/wrapt-1.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4011d137b9955791f9084749cba9a367c68d50ab8d11d64c50ba1688c9b457f2", size = 81716, upload-time = "2025-01-14T10:33:27.372Z" },
- { url = "https://files.pythonhosted.org/packages/7e/09/dccf68fa98e862df7e6a60a61d43d644b7d095a5fc36dbb591bbd4a1c7b2/wrapt-1.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1473400e5b2733e58b396a04eb7f35f541e1fb976d0c0724d0223dd607e0f74c", size = 74548, upload-time = "2025-01-14T10:33:28.52Z" },
- { url = "https://files.pythonhosted.org/packages/b7/8e/067021fa3c8814952c5e228d916963c1115b983e21393289de15128e867e/wrapt-1.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3cedbfa9c940fdad3e6e941db7138e26ce8aad38ab5fe9dcfadfed9db7a54e62", size = 81334, upload-time = "2025-01-14T10:33:29.643Z" },
- { url = "https://files.pythonhosted.org/packages/4b/0d/9d4b5219ae4393f718699ca1c05f5ebc0c40d076f7e65fd48f5f693294fb/wrapt-1.17.2-cp310-cp310-win32.whl", hash = "sha256:582530701bff1dec6779efa00c516496968edd851fba224fbd86e46cc6b73563", size = 36427, upload-time = "2025-01-14T10:33:30.832Z" },
- { url = "https://files.pythonhosted.org/packages/72/6a/c5a83e8f61aec1e1aeef939807602fb880e5872371e95df2137142f5c58e/wrapt-1.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:58705da316756681ad3c9c73fd15499aa4d8c69f9fd38dc8a35e06c12468582f", size = 38774, upload-time = "2025-01-14T10:33:32.897Z" },
- { url = "https://files.pythonhosted.org/packages/cd/f7/a2aab2cbc7a665efab072344a8949a71081eed1d2f451f7f7d2b966594a2/wrapt-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58", size = 53308, upload-time = "2025-01-14T10:33:33.992Z" },
- { url = "https://files.pythonhosted.org/packages/50/ff/149aba8365fdacef52b31a258c4dc1c57c79759c335eff0b3316a2664a64/wrapt-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda", size = 38488, upload-time = "2025-01-14T10:33:35.264Z" },
- { url = "https://files.pythonhosted.org/packages/65/46/5a917ce85b5c3b490d35c02bf71aedaa9f2f63f2d15d9949cc4ba56e8ba9/wrapt-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438", size = 38776, upload-time = "2025-01-14T10:33:38.28Z" },
- { url = "https://files.pythonhosted.org/packages/ca/74/336c918d2915a4943501c77566db41d1bd6e9f4dbc317f356b9a244dfe83/wrapt-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a", size = 83776, upload-time = "2025-01-14T10:33:40.678Z" },
- { url = "https://files.pythonhosted.org/packages/09/99/c0c844a5ccde0fe5761d4305485297f91d67cf2a1a824c5f282e661ec7ff/wrapt-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000", size = 75420, upload-time = "2025-01-14T10:33:41.868Z" },
- { url = "https://files.pythonhosted.org/packages/b4/b0/9fc566b0fe08b282c850063591a756057c3247b2362b9286429ec5bf1721/wrapt-1.17.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6", size = 83199, upload-time = "2025-01-14T10:33:43.598Z" },
- { url = "https://files.pythonhosted.org/packages/9d/4b/71996e62d543b0a0bd95dda485219856def3347e3e9380cc0d6cf10cfb2f/wrapt-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b", size = 82307, upload-time = "2025-01-14T10:33:48.499Z" },
- { url = "https://files.pythonhosted.org/packages/39/35/0282c0d8789c0dc9bcc738911776c762a701f95cfe113fb8f0b40e45c2b9/wrapt-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662", size = 75025, upload-time = "2025-01-14T10:33:51.191Z" },
- { url = "https://files.pythonhosted.org/packages/4f/6d/90c9fd2c3c6fee181feecb620d95105370198b6b98a0770cba090441a828/wrapt-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72", size = 81879, upload-time = "2025-01-14T10:33:52.328Z" },
- { url = "https://files.pythonhosted.org/packages/8f/fa/9fb6e594f2ce03ef03eddbdb5f4f90acb1452221a5351116c7c4708ac865/wrapt-1.17.2-cp311-cp311-win32.whl", hash = "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317", size = 36419, upload-time = "2025-01-14T10:33:53.551Z" },
- { url = "https://files.pythonhosted.org/packages/47/f8/fb1773491a253cbc123c5d5dc15c86041f746ed30416535f2a8df1f4a392/wrapt-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3", size = 38773, upload-time = "2025-01-14T10:33:56.323Z" },
- { url = "https://files.pythonhosted.org/packages/a1/bd/ab55f849fd1f9a58ed7ea47f5559ff09741b25f00c191231f9f059c83949/wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925", size = 53799, upload-time = "2025-01-14T10:33:57.4Z" },
- { url = "https://files.pythonhosted.org/packages/53/18/75ddc64c3f63988f5a1d7e10fb204ffe5762bc663f8023f18ecaf31a332e/wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392", size = 38821, upload-time = "2025-01-14T10:33:59.334Z" },
- { url = "https://files.pythonhosted.org/packages/48/2a/97928387d6ed1c1ebbfd4efc4133a0633546bec8481a2dd5ec961313a1c7/wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40", size = 38919, upload-time = "2025-01-14T10:34:04.093Z" },
- { url = "https://files.pythonhosted.org/packages/73/54/3bfe5a1febbbccb7a2f77de47b989c0b85ed3a6a41614b104204a788c20e/wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d", size = 88721, upload-time = "2025-01-14T10:34:07.163Z" },
- { url = "https://files.pythonhosted.org/packages/25/cb/7262bc1b0300b4b64af50c2720ef958c2c1917525238d661c3e9a2b71b7b/wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b", size = 80899, upload-time = "2025-01-14T10:34:09.82Z" },
- { url = "https://files.pythonhosted.org/packages/2a/5a/04cde32b07a7431d4ed0553a76fdb7a61270e78c5fd5a603e190ac389f14/wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98", size = 89222, upload-time = "2025-01-14T10:34:11.258Z" },
- { url = "https://files.pythonhosted.org/packages/09/28/2e45a4f4771fcfb109e244d5dbe54259e970362a311b67a965555ba65026/wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82", size = 86707, upload-time = "2025-01-14T10:34:12.49Z" },
- { url = "https://files.pythonhosted.org/packages/c6/d2/dcb56bf5f32fcd4bd9aacc77b50a539abdd5b6536872413fd3f428b21bed/wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae", size = 79685, upload-time = "2025-01-14T10:34:15.043Z" },
- { url = "https://files.pythonhosted.org/packages/80/4e/eb8b353e36711347893f502ce91c770b0b0929f8f0bed2670a6856e667a9/wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9", size = 87567, upload-time = "2025-01-14T10:34:16.563Z" },
- { url = "https://files.pythonhosted.org/packages/17/27/4fe749a54e7fae6e7146f1c7d914d28ef599dacd4416566c055564080fe2/wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9", size = 36672, upload-time = "2025-01-14T10:34:17.727Z" },
- { url = "https://files.pythonhosted.org/packages/15/06/1dbf478ea45c03e78a6a8c4be4fdc3c3bddea5c8de8a93bc971415e47f0f/wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991", size = 38865, upload-time = "2025-01-14T10:34:19.577Z" },
- { url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800, upload-time = "2025-01-14T10:34:21.571Z" },
- { url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824, upload-time = "2025-01-14T10:34:22.999Z" },
- { url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920, upload-time = "2025-01-14T10:34:25.386Z" },
- { url = "https://files.pythonhosted.org/packages/3b/24/11c4510de906d77e0cfb5197f1b1445d4fec42c9a39ea853d482698ac681/wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8", size = 88690, upload-time = "2025-01-14T10:34:28.058Z" },
- { url = "https://files.pythonhosted.org/packages/71/d7/cfcf842291267bf455b3e266c0c29dcb675b5540ee8b50ba1699abf3af45/wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6", size = 80861, upload-time = "2025-01-14T10:34:29.167Z" },
- { url = "https://files.pythonhosted.org/packages/d5/66/5d973e9f3e7370fd686fb47a9af3319418ed925c27d72ce16b791231576d/wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc", size = 89174, upload-time = "2025-01-14T10:34:31.702Z" },
- { url = "https://files.pythonhosted.org/packages/a7/d3/8e17bb70f6ae25dabc1aaf990f86824e4fd98ee9cadf197054e068500d27/wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2", size = 86721, upload-time = "2025-01-14T10:34:32.91Z" },
- { url = "https://files.pythonhosted.org/packages/6f/54/f170dfb278fe1c30d0ff864513cff526d624ab8de3254b20abb9cffedc24/wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b", size = 79763, upload-time = "2025-01-14T10:34:34.903Z" },
- { url = "https://files.pythonhosted.org/packages/4a/98/de07243751f1c4a9b15c76019250210dd3486ce098c3d80d5f729cba029c/wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504", size = 87585, upload-time = "2025-01-14T10:34:36.13Z" },
- { url = "https://files.pythonhosted.org/packages/f9/f0/13925f4bd6548013038cdeb11ee2cbd4e37c30f8bfd5db9e5a2a370d6e20/wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a", size = 36676, upload-time = "2025-01-14T10:34:37.962Z" },
- { url = "https://files.pythonhosted.org/packages/bf/ae/743f16ef8c2e3628df3ddfd652b7d4c555d12c84b53f3d8218498f4ade9b/wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845", size = 38871, upload-time = "2025-01-14T10:34:39.13Z" },
- { url = "https://files.pythonhosted.org/packages/3d/bc/30f903f891a82d402ffb5fda27ec1d621cc97cb74c16fea0b6141f1d4e87/wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192", size = 56312, upload-time = "2025-01-14T10:34:40.604Z" },
- { url = "https://files.pythonhosted.org/packages/8a/04/c97273eb491b5f1c918857cd26f314b74fc9b29224521f5b83f872253725/wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b", size = 40062, upload-time = "2025-01-14T10:34:45.011Z" },
- { url = "https://files.pythonhosted.org/packages/4e/ca/3b7afa1eae3a9e7fefe499db9b96813f41828b9fdb016ee836c4c379dadb/wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0", size = 40155, upload-time = "2025-01-14T10:34:47.25Z" },
- { url = "https://files.pythonhosted.org/packages/89/be/7c1baed43290775cb9030c774bc53c860db140397047cc49aedaf0a15477/wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306", size = 113471, upload-time = "2025-01-14T10:34:50.934Z" },
- { url = "https://files.pythonhosted.org/packages/32/98/4ed894cf012b6d6aae5f5cc974006bdeb92f0241775addad3f8cd6ab71c8/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb", size = 101208, upload-time = "2025-01-14T10:34:52.297Z" },
- { url = "https://files.pythonhosted.org/packages/ea/fd/0c30f2301ca94e655e5e057012e83284ce8c545df7661a78d8bfca2fac7a/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681", size = 109339, upload-time = "2025-01-14T10:34:53.489Z" },
- { url = "https://files.pythonhosted.org/packages/75/56/05d000de894c4cfcb84bcd6b1df6214297b8089a7bd324c21a4765e49b14/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6", size = 110232, upload-time = "2025-01-14T10:34:55.327Z" },
- { url = "https://files.pythonhosted.org/packages/53/f8/c3f6b2cf9b9277fb0813418e1503e68414cd036b3b099c823379c9575e6d/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6", size = 100476, upload-time = "2025-01-14T10:34:58.055Z" },
- { url = "https://files.pythonhosted.org/packages/a7/b1/0bb11e29aa5139d90b770ebbfa167267b1fc548d2302c30c8f7572851738/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f", size = 106377, upload-time = "2025-01-14T10:34:59.3Z" },
- { url = "https://files.pythonhosted.org/packages/6a/e1/0122853035b40b3f333bbb25f1939fc1045e21dd518f7f0922b60c156f7c/wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", size = 37986, upload-time = "2025-01-14T10:35:00.498Z" },
- { url = "https://files.pythonhosted.org/packages/09/5e/1655cf481e079c1f22d0cabdd4e51733679932718dc23bf2db175f329b76/wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", size = 40750, upload-time = "2025-01-14T10:35:03.378Z" },
- { url = "https://files.pythonhosted.org/packages/8a/f4/6ed2b8f6f1c832933283974839b88ec7c983fd12905e01e97889dadf7559/wrapt-1.17.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99039fa9e6306880572915728d7f6c24a86ec57b0a83f6b2491e1d8ab0235b9a", size = 53308, upload-time = "2025-01-14T10:35:24.413Z" },
- { url = "https://files.pythonhosted.org/packages/a2/a9/712a53f8f4f4545768ac532619f6e56d5d0364a87b2212531685e89aeef8/wrapt-1.17.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2696993ee1eebd20b8e4ee4356483c4cb696066ddc24bd70bcbb80fa56ff9061", size = 38489, upload-time = "2025-01-14T10:35:26.913Z" },
- { url = "https://files.pythonhosted.org/packages/fa/9b/e172c8f28a489a2888df18f953e2f6cb8d33b1a2e78c9dfc52d8bf6a5ead/wrapt-1.17.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:612dff5db80beef9e649c6d803a8d50c409082f1fedc9dbcdfde2983b2025b82", size = 38776, upload-time = "2025-01-14T10:35:28.183Z" },
- { url = "https://files.pythonhosted.org/packages/cf/cb/7a07b51762dcd59bdbe07aa97f87b3169766cadf240f48d1cbe70a1be9db/wrapt-1.17.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c2caa1585c82b3f7a7ab56afef7b3602021d6da34fbc1cf234ff139fed3cd9", size = 83050, upload-time = "2025-01-14T10:35:30.645Z" },
- { url = "https://files.pythonhosted.org/packages/a5/51/a42757dd41032afd6d8037617aa3bc6803ba971850733b24dfb7d5c627c4/wrapt-1.17.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c958bcfd59bacc2d0249dcfe575e71da54f9dcf4a8bdf89c4cb9a68a1170d73f", size = 74718, upload-time = "2025-01-14T10:35:32.047Z" },
- { url = "https://files.pythonhosted.org/packages/bf/bb/d552bfe47db02fcfc950fc563073a33500f8108efa5f7b41db2f83a59028/wrapt-1.17.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc78a84e2dfbc27afe4b2bd7c80c8db9bca75cc5b85df52bfe634596a1da846b", size = 82590, upload-time = "2025-01-14T10:35:33.329Z" },
- { url = "https://files.pythonhosted.org/packages/77/99/77b06b3c3c410dbae411105bf22496facf03a5496bfaca8fbcf9da381889/wrapt-1.17.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba0f0eb61ef00ea10e00eb53a9129501f52385c44853dbd6c4ad3f403603083f", size = 81462, upload-time = "2025-01-14T10:35:34.933Z" },
- { url = "https://files.pythonhosted.org/packages/2d/21/cf0bd85ae66f92600829ea1de8e1da778e5e9f6e574ccbe74b66db0d95db/wrapt-1.17.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1e1fe0e6ab7775fd842bc39e86f6dcfc4507ab0ffe206093e76d61cde37225c8", size = 74309, upload-time = "2025-01-14T10:35:37.542Z" },
- { url = "https://files.pythonhosted.org/packages/6d/16/112d25e9092398a0dd6fec50ab7ac1b775a0c19b428f049785096067ada9/wrapt-1.17.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c86563182421896d73858e08e1db93afdd2b947a70064b813d515d66549e15f9", size = 81081, upload-time = "2025-01-14T10:35:38.9Z" },
- { url = "https://files.pythonhosted.org/packages/2b/49/364a615a0cc0872685646c495c7172e4fc7bf1959e3b12a1807a03014e05/wrapt-1.17.2-cp39-cp39-win32.whl", hash = "sha256:f393cda562f79828f38a819f4788641ac7c4085f30f1ce1a68672baa686482bb", size = 36423, upload-time = "2025-01-14T10:35:40.177Z" },
- { url = "https://files.pythonhosted.org/packages/00/ad/5d2c1b34ba3202cd833d9221833e74d6500ce66730974993a8dc9a94fb8c/wrapt-1.17.2-cp39-cp39-win_amd64.whl", hash = "sha256:36ccae62f64235cf8ddb682073a60519426fdd4725524ae38874adf72b5f2aeb", size = 38772, upload-time = "2025-01-14T10:35:42.763Z" },
- { url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594, upload-time = "2025-01-14T10:35:44.018Z" },
+version = "2.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/0d/12d8c803ed2ce4e5e7d5b9f5f602721f9dfef82c95959f3ce97fa584bb5c/wrapt-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:64b103acdaa53b7caf409e8d45d39a8442fe6dcfec6ba3f3d141e0cc2b5b4dbd", size = 77481, upload-time = "2025-11-07T00:43:11.103Z" },
+ { url = "https://files.pythonhosted.org/packages/05/3e/4364ebe221ebf2a44d9fc8695a19324692f7dd2795e64bd59090856ebf12/wrapt-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:91bcc576260a274b169c3098e9a3519fb01f2989f6d3d386ef9cbf8653de1374", size = 60692, upload-time = "2025-11-07T00:43:13.697Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/ff/ae2a210022b521f86a8ddcdd6058d137c051003812b0388a5e9a03d3fe10/wrapt-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ab594f346517010050126fcd822697b25a7031d815bb4fbc238ccbe568216489", size = 61574, upload-time = "2025-11-07T00:43:14.967Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/93/5cf92edd99617095592af919cb81d4bff61c5dbbb70d3c92099425a8ec34/wrapt-2.0.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:36982b26f190f4d737f04a492a68accbfc6fa042c3f42326fdfbb6c5b7a20a31", size = 113688, upload-time = "2025-11-07T00:43:18.275Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/0a/e38fc0cee1f146c9fb266d8ef96ca39fb14a9eef165383004019aa53f88a/wrapt-2.0.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23097ed8bc4c93b7bf36fa2113c6c733c976316ce0ee2c816f64ca06102034ef", size = 115698, upload-time = "2025-11-07T00:43:19.407Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/85/bef44ea018b3925fb0bcbe9112715f665e4d5309bd945191da814c314fd1/wrapt-2.0.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bacfe6e001749a3b64db47bcf0341da757c95959f592823a93931a422395013", size = 112096, upload-time = "2025-11-07T00:43:16.5Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/0b/733a2376e413117e497aa1a5b1b78e8f3a28c0e9537d26569f67d724c7c5/wrapt-2.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8ec3303e8a81932171f455f792f8df500fc1a09f20069e5c16bd7049ab4e8e38", size = 114878, upload-time = "2025-11-07T00:43:20.81Z" },
+ { url = "https://files.pythonhosted.org/packages/da/03/d81dcb21bbf678fcda656495792b059f9d56677d119ca022169a12542bd0/wrapt-2.0.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3f373a4ab5dbc528a94334f9fe444395b23c2f5332adab9ff4ea82f5a9e33bc1", size = 111298, upload-time = "2025-11-07T00:43:22.229Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/d5/5e623040e8056e1108b787020d56b9be93dbbf083bf2324d42cde80f3a19/wrapt-2.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f49027b0b9503bf6c8cdc297ca55006b80c2f5dd36cecc72c6835ab6e10e8a25", size = 113361, upload-time = "2025-11-07T00:43:24.301Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/f3/de535ccecede6960e28c7b722e5744846258111d6c9f071aa7578ea37ad3/wrapt-2.0.1-cp310-cp310-win32.whl", hash = "sha256:8330b42d769965e96e01fa14034b28a2a7600fbf7e8f0cc90ebb36d492c993e4", size = 58035, upload-time = "2025-11-07T00:43:28.96Z" },
+ { url = "https://files.pythonhosted.org/packages/21/15/39d3ca5428a70032c2ec8b1f1c9d24c32e497e7ed81aed887a4998905fcc/wrapt-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:1218573502a8235bb8a7ecaed12736213b22dcde9feab115fa2989d42b5ded45", size = 60383, upload-time = "2025-11-07T00:43:25.804Z" },
+ { url = "https://files.pythonhosted.org/packages/43/c2/dfd23754b7f7a4dce07e08f4309c4e10a40046a83e9ae1800f2e6b18d7c1/wrapt-2.0.1-cp310-cp310-win_arm64.whl", hash = "sha256:eda8e4ecd662d48c28bb86be9e837c13e45c58b8300e43ba3c9b4fa9900302f7", size = 58894, upload-time = "2025-11-07T00:43:27.074Z" },
+ { url = "https://files.pythonhosted.org/packages/98/60/553997acf3939079dab022e37b67b1904b5b0cc235503226898ba573b10c/wrapt-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e17283f533a0d24d6e5429a7d11f250a58d28b4ae5186f8f47853e3e70d2590", size = 77480, upload-time = "2025-11-07T00:43:30.573Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/50/e5b3d30895d77c52105c6d5cbf94d5b38e2a3dd4a53d22d246670da98f7c/wrapt-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85df8d92158cb8f3965aecc27cf821461bb5f40b450b03facc5d9f0d4d6ddec6", size = 60690, upload-time = "2025-11-07T00:43:31.594Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/40/660b2898703e5cbbb43db10cdefcc294274458c3ca4c68637c2b99371507/wrapt-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1be685ac7700c966b8610ccc63c3187a72e33cab53526a27b2a285a662cd4f7", size = 61578, upload-time = "2025-11-07T00:43:32.918Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/36/825b44c8a10556957bc0c1d84c7b29a40e05fcf1873b6c40aa9dbe0bd972/wrapt-2.0.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df0b6d3b95932809c5b3fecc18fda0f1e07452d05e2662a0b35548985f256e28", size = 114115, upload-time = "2025-11-07T00:43:35.605Z" },
+ { url = "https://files.pythonhosted.org/packages/83/73/0a5d14bb1599677304d3c613a55457d34c344e9b60eda8a737c2ead7619e/wrapt-2.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da7384b0e5d4cae05c97cd6f94faaf78cc8b0f791fc63af43436d98c4ab37bb", size = 116157, upload-time = "2025-11-07T00:43:37.058Z" },
+ { url = "https://files.pythonhosted.org/packages/01/22/1c158fe763dbf0a119f985d945711d288994fe5514c0646ebe0eb18b016d/wrapt-2.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ec65a78fbd9d6f083a15d7613b2800d5663dbb6bb96003899c834beaa68b242c", size = 112535, upload-time = "2025-11-07T00:43:34.138Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/28/4f16861af67d6de4eae9927799b559c20ebdd4fe432e89ea7fe6fcd9d709/wrapt-2.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7de3cc939be0e1174969f943f3b44e0d79b6f9a82198133a5b7fc6cc92882f16", size = 115404, upload-time = "2025-11-07T00:43:39.214Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/8b/7960122e625fad908f189b59c4aae2d50916eb4098b0fb2819c5a177414f/wrapt-2.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fb1a5b72cbd751813adc02ef01ada0b0d05d3dcbc32976ce189a1279d80ad4a2", size = 111802, upload-time = "2025-11-07T00:43:40.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/73/7881eee5ac31132a713ab19a22c9e5f1f7365c8b1df50abba5d45b781312/wrapt-2.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3fa272ca34332581e00bf7773e993d4f632594eb2d1b0b162a9038df0fd971dd", size = 113837, upload-time = "2025-11-07T00:43:42.921Z" },
+ { url = "https://files.pythonhosted.org/packages/45/00/9499a3d14e636d1f7089339f96c4409bbc7544d0889f12264efa25502ae8/wrapt-2.0.1-cp311-cp311-win32.whl", hash = "sha256:fc007fdf480c77301ab1afdbb6ab22a5deee8885f3b1ed7afcb7e5e84a0e27be", size = 58028, upload-time = "2025-11-07T00:43:47.369Z" },
+ { url = "https://files.pythonhosted.org/packages/70/5d/8f3d7eea52f22638748f74b102e38fdf88cb57d08ddeb7827c476a20b01b/wrapt-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:47434236c396d04875180171ee1f3815ca1eada05e24a1ee99546320d54d1d1b", size = 60385, upload-time = "2025-11-07T00:43:44.34Z" },
+ { url = "https://files.pythonhosted.org/packages/14/e2/32195e57a8209003587bbbad44d5922f13e0ced2a493bb46ca882c5b123d/wrapt-2.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:837e31620e06b16030b1d126ed78e9383815cbac914693f54926d816d35d8edf", size = 58893, upload-time = "2025-11-07T00:43:46.161Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/73/8cb252858dc8254baa0ce58ce382858e3a1cf616acebc497cb13374c95c6/wrapt-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1fdbb34da15450f2b1d735a0e969c24bdb8d8924892380126e2a293d9902078c", size = 78129, upload-time = "2025-11-07T00:43:48.852Z" },
+ { url = "https://files.pythonhosted.org/packages/19/42/44a0db2108526ee6e17a5ab72478061158f34b08b793df251d9fbb9a7eb4/wrapt-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d32794fe940b7000f0519904e247f902f0149edbe6316c710a8562fb6738841", size = 61205, upload-time = "2025-11-07T00:43:50.402Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/8a/5b4b1e44b791c22046e90d9b175f9a7581a8cc7a0debbb930f81e6ae8e25/wrapt-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:386fb54d9cd903ee0012c09291336469eb7b244f7183d40dc3e86a16a4bace62", size = 61692, upload-time = "2025-11-07T00:43:51.678Z" },
+ { url = "https://files.pythonhosted.org/packages/11/53/3e794346c39f462bcf1f58ac0487ff9bdad02f9b6d5ee2dc84c72e0243b2/wrapt-2.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7b219cb2182f230676308cdcacd428fa837987b89e4b7c5c9025088b8a6c9faf", size = 121492, upload-time = "2025-11-07T00:43:55.017Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/7e/10b7b0e8841e684c8ca76b462a9091c45d62e8f2de9c4b1390b690eadf16/wrapt-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:641e94e789b5f6b4822bb8d8ebbdfc10f4e4eae7756d648b717d980f657a9eb9", size = 123064, upload-time = "2025-11-07T00:43:56.323Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/d1/3c1e4321fc2f5ee7fd866b2d822aa89b84495f28676fd976c47327c5b6aa/wrapt-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe21b118b9f58859b5ebaa4b130dee18669df4bd111daad082b7beb8799ad16b", size = 117403, upload-time = "2025-11-07T00:43:53.258Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/b0/d2f0a413cf201c8c2466de08414a15420a25aa83f53e647b7255cc2fab5d/wrapt-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17fb85fa4abc26a5184d93b3efd2dcc14deb4b09edcdb3535a536ad34f0b4dba", size = 121500, upload-time = "2025-11-07T00:43:57.468Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/45/bddb11d28ca39970a41ed48a26d210505120f925918592283369219f83cc/wrapt-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b89ef9223d665ab255ae42cc282d27d69704d94be0deffc8b9d919179a609684", size = 116299, upload-time = "2025-11-07T00:43:58.877Z" },
+ { url = "https://files.pythonhosted.org/packages/81/af/34ba6dd570ef7a534e7eec0c25e2615c355602c52aba59413411c025a0cb/wrapt-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a453257f19c31b31ba593c30d997d6e5be39e3b5ad9148c2af5a7314061c63eb", size = 120622, upload-time = "2025-11-07T00:43:59.962Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/3e/693a13b4146646fb03254636f8bafd20c621955d27d65b15de07ab886187/wrapt-2.0.1-cp312-cp312-win32.whl", hash = "sha256:3e271346f01e9c8b1130a6a3b0e11908049fe5be2d365a5f402778049147e7e9", size = 58246, upload-time = "2025-11-07T00:44:03.169Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/36/715ec5076f925a6be95f37917b66ebbeaa1372d1862c2ccd7a751574b068/wrapt-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2da620b31a90cdefa9cd0c2b661882329e2e19d1d7b9b920189956b76c564d75", size = 60492, upload-time = "2025-11-07T00:44:01.027Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/3e/62451cd7d80f65cc125f2b426b25fbb6c514bf6f7011a0c3904fc8c8df90/wrapt-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:aea9c7224c302bc8bfc892b908537f56c430802560e827b75ecbde81b604598b", size = 58987, upload-time = "2025-11-07T00:44:02.095Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" },
+ { url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" },
+ { url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" },
+ { url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" },
+ { url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" },
+ { url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" },
+ { url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" },
+ { url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" },
+ { url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" },
+ { url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" },
+ { url = "https://files.pythonhosted.org/packages/73/81/d08d83c102709258e7730d3cd25befd114c60e43ef3891d7e6877971c514/wrapt-2.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5e53b428f65ece6d9dad23cb87e64506392b720a0b45076c05354d27a13351a1", size = 78290, upload-time = "2025-11-07T00:44:34.691Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/14/393afba2abb65677f313aa680ff0981e829626fed39b6a7e3ec807487790/wrapt-2.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ad3ee9d0f254851c71780966eb417ef8e72117155cff04821ab9b60549694a55", size = 61255, upload-time = "2025-11-07T00:44:35.762Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/10/a4a1f2fba205a9462e36e708ba37e5ac95f4987a0f1f8fd23f0bf1fc3b0f/wrapt-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7b822c61ed04ee6ad64bc90d13368ad6eb094db54883b5dde2182f67a7f22c0", size = 61797, upload-time = "2025-11-07T00:44:37.22Z" },
+ { url = "https://files.pythonhosted.org/packages/12/db/99ba5c37cf1c4fad35349174f1e38bd8d992340afc1ff27f526729b98986/wrapt-2.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7164a55f5e83a9a0b031d3ffab4d4e36bbec42e7025db560f225489fa929e509", size = 120470, upload-time = "2025-11-07T00:44:39.425Z" },
+ { url = "https://files.pythonhosted.org/packages/30/3f/a1c8d2411eb826d695fc3395a431757331582907a0ec59afce8fe8712473/wrapt-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60690ba71a57424c8d9ff28f8d006b7ad7772c22a4af432188572cd7fa004a1", size = 122851, upload-time = "2025-11-07T00:44:40.582Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/8d/72c74a63f201768d6a04a8845c7976f86be6f5ff4d74996c272cefc8dafc/wrapt-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3cd1a4bd9a7a619922a8557e1318232e7269b5fb69d4ba97b04d20450a6bf970", size = 117433, upload-time = "2025-11-07T00:44:38.313Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/5a/df37cf4042cb13b08256f8e27023e2f9b3d471d553376616591bb99bcb31/wrapt-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4c2e3d777e38e913b8ce3a6257af72fb608f86a1df471cb1d4339755d0a807c", size = 121280, upload-time = "2025-11-07T00:44:41.69Z" },
+ { url = "https://files.pythonhosted.org/packages/54/34/40d6bc89349f9931e1186ceb3e5fbd61d307fef814f09fbbac98ada6a0c8/wrapt-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3d366aa598d69416b5afedf1faa539fac40c1d80a42f6b236c88c73a3c8f2d41", size = 116343, upload-time = "2025-11-07T00:44:43.013Z" },
+ { url = "https://files.pythonhosted.org/packages/70/66/81c3461adece09d20781dee17c2366fdf0cb8754738b521d221ca056d596/wrapt-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c235095d6d090aa903f1db61f892fffb779c1eaeb2a50e566b52001f7a0f66ed", size = 119650, upload-time = "2025-11-07T00:44:44.523Z" },
+ { url = "https://files.pythonhosted.org/packages/46/3a/d0146db8be8761a9e388cc9cc1c312b36d583950ec91696f19bbbb44af5a/wrapt-2.0.1-cp314-cp314-win32.whl", hash = "sha256:bfb5539005259f8127ea9c885bdc231978c06b7a980e63a8a61c8c4c979719d0", size = 58701, upload-time = "2025-11-07T00:44:48.277Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/38/5359da9af7d64554be63e9046164bd4d8ff289a2dd365677d25ba3342c08/wrapt-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:4ae879acc449caa9ed43fc36ba08392b9412ee67941748d31d94e3cedb36628c", size = 60947, upload-time = "2025-11-07T00:44:46.086Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/3f/96db0619276a833842bf36343685fa04f987dd6e3037f314531a1e00492b/wrapt-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:8639b843c9efd84675f1e100ed9e99538ebea7297b62c4b45a7042edb84db03e", size = 59359, upload-time = "2025-11-07T00:44:47.164Z" },
+ { url = "https://files.pythonhosted.org/packages/71/49/5f5d1e867bf2064bf3933bc6cf36ade23505f3902390e175e392173d36a2/wrapt-2.0.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:9219a1d946a9b32bb23ccae66bdb61e35c62773ce7ca6509ceea70f344656b7b", size = 82031, upload-time = "2025-11-07T00:44:49.4Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/89/0009a218d88db66ceb83921e5685e820e2c61b59bbbb1324ba65342668bc/wrapt-2.0.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fa4184e74197af3adad3c889a1af95b53bb0466bced92ea99a0c014e48323eec", size = 62952, upload-time = "2025-11-07T00:44:50.74Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/18/9b968e920dd05d6e44bcc918a046d02afea0fb31b2f1c80ee4020f377cbe/wrapt-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c5ef2f2b8a53b7caee2f797ef166a390fef73979b15778a4a153e4b5fedce8fa", size = 63688, upload-time = "2025-11-07T00:44:52.248Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/7d/78bdcb75826725885d9ea26c49a03071b10c4c92da93edda612910f150e4/wrapt-2.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e042d653a4745be832d5aa190ff80ee4f02c34b21f4b785745eceacd0907b815", size = 152706, upload-time = "2025-11-07T00:44:54.613Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/77/cac1d46f47d32084a703df0d2d29d47e7eb2a7d19fa5cbca0e529ef57659/wrapt-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2afa23318136709c4b23d87d543b425c399887b4057936cd20386d5b1422b6fa", size = 158866, upload-time = "2025-11-07T00:44:55.79Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/11/b521406daa2421508903bf8d5e8b929216ec2af04839db31c0a2c525eee0/wrapt-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c72328f668cf4c503ffcf9434c2b71fdd624345ced7941bc6693e61bbe36bef", size = 146148, upload-time = "2025-11-07T00:44:53.388Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c0/340b272bed297baa7c9ce0c98ef7017d9c035a17a6a71dce3184b8382da2/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3793ac154afb0e5b45d1233cb94d354ef7a983708cc3bb12563853b1d8d53747", size = 155737, upload-time = "2025-11-07T00:44:56.971Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/93/bfcb1fb2bdf186e9c2883a4d1ab45ab099c79cbf8f4e70ea453811fa3ea7/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fec0d993ecba3991645b4857837277469c8cc4c554a7e24d064d1ca291cfb81f", size = 144451, upload-time = "2025-11-07T00:44:58.515Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/6b/dca504fb18d971139d232652656180e3bd57120e1193d9a5899c3c0b7cdd/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:949520bccc1fa227274da7d03bf238be15389cd94e32e4297b92337df9b7a349", size = 150353, upload-time = "2025-11-07T00:44:59.753Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/f6/a1de4bd3653afdf91d250ca5c721ee51195df2b61a4603d4b373aa804d1d/wrapt-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:be9e84e91d6497ba62594158d3d31ec0486c60055c49179edc51ee43d095f79c", size = 60609, upload-time = "2025-11-07T00:45:03.315Z" },
+ { url = "https://files.pythonhosted.org/packages/01/3a/07cd60a9d26fe73efead61c7830af975dfdba8537632d410462672e4432b/wrapt-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:61c4956171c7434634401db448371277d07032a81cc21c599c22953374781395", size = 64038, upload-time = "2025-11-07T00:45:00.948Z" },
+ { url = "https://files.pythonhosted.org/packages/41/99/8a06b8e17dddbf321325ae4eb12465804120f699cd1b8a355718300c62da/wrapt-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:35cdbd478607036fee40273be8ed54a451f5f23121bd9d4be515158f9498f7ad", size = 60634, upload-time = "2025-11-07T00:45:02.087Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/1f/5af0ae22368ec69067a577f9e07a0dd2619a1f63aabc2851263679942667/wrapt-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:68424221a2dc00d634b54f92441914929c5ffb1c30b3b837343978343a3512a3", size = 77478, upload-time = "2025-11-07T00:45:16.65Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/b7/fd6b563aada859baabc55db6aa71b8afb4a3ceb8bc33d1053e4c7b5e0109/wrapt-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd1a18f5a797fe740cb3d7a0e853a8ce6461cc62023b630caec80171a6b8097", size = 60687, upload-time = "2025-11-07T00:45:17.896Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/8c/9ededfff478af396bcd081076986904bdca336d9664d247094150c877dcb/wrapt-2.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fb3a86e703868561c5cad155a15c36c716e1ab513b7065bd2ac8ed353c503333", size = 61563, upload-time = "2025-11-07T00:45:19.109Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/a7/d795a1aa2b6ab20ca21157fe03cbfc6aa7e870a88ac3b4ea189e2f6c79f0/wrapt-2.0.1-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5dc1b852337c6792aa111ca8becff5bacf576bf4a0255b0f05eb749da6a1643e", size = 113395, upload-time = "2025-11-07T00:45:21.551Z" },
+ { url = "https://files.pythonhosted.org/packages/61/32/56cde2bbf95f2d5698a1850a765520aa86bc7ae0f95b8ec80b6f2e2049bb/wrapt-2.0.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c046781d422f0830de6329fa4b16796096f28a92c8aef3850674442cdcb87b7f", size = 115362, upload-time = "2025-11-07T00:45:22.809Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/53/8d3cc433847c219212c133a3e8305bd087b386ef44442ff39189e8fa62ac/wrapt-2.0.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f73f9f7a0ebd0db139253d27e5fc8d2866ceaeef19c30ab5d69dcbe35e1a6981", size = 111766, upload-time = "2025-11-07T00:45:20.294Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/d3/14b50c2d0463c0dcef8f388cb1527ed7bbdf0972b9fd9976905f36c77ebf/wrapt-2.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b667189cf8efe008f55bbda321890bef628a67ab4147ebf90d182f2dadc78790", size = 114560, upload-time = "2025-11-07T00:45:24.054Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/b8/4f731ff178f77ae55385586de9ff4b4261e872cf2ced4875e6c976fbcb8b/wrapt-2.0.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:a9a83618c4f0757557c077ef71d708ddd9847ed66b7cc63416632af70d3e2308", size = 110999, upload-time = "2025-11-07T00:45:25.596Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/bb/5f1bb0f9ae9d12e19f1d71993d052082062603e83fe3e978377f918f054d/wrapt-2.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e9b121e9aeb15df416c2c960b8255a49d44b4038016ee17af03975992d03931", size = 113164, upload-time = "2025-11-07T00:45:26.8Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/f6/f3a3c623d3065c7bf292ee0b73566236b562d5ed894891bd8e435762b618/wrapt-2.0.1-cp39-cp39-win32.whl", hash = "sha256:1f186e26ea0a55f809f232e92cc8556a0977e00183c3ebda039a807a42be1494", size = 58028, upload-time = "2025-11-07T00:45:30.943Z" },
+ { url = "https://files.pythonhosted.org/packages/24/78/647c609dfa18063a7fcd5c23f762dd006be401cc9206314d29c9b0b12078/wrapt-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:bf4cb76f36be5de950ce13e22e7fdf462b35b04665a12b64f3ac5c1bbbcf3728", size = 60380, upload-time = "2025-11-07T00:45:28.341Z" },
+ { url = "https://files.pythonhosted.org/packages/07/90/0c14b241d18d80ddf4c847a5f52071e126e8a6a9e5a8a7952add8ef0d766/wrapt-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:d6cc985b9c8b235bd933990cdbf0f891f8e010b65a3911f7a55179cd7b0fc57b", size = 58895, upload-time = "2025-11-07T00:45:29.527Z" },
+ { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" },
]
[[package]]
name = "yarl"
-version = "1.20.0"
+version = "1.22.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/62/51/c0edba5219027f6eab262e139f73e2417b0f4efffa23bf562f6e18f76ca5/yarl-1.20.0.tar.gz", hash = "sha256:686d51e51ee5dfe62dec86e4866ee0e9ed66df700d55c828a615640adc885307", size = 185258, upload-time = "2025-04-17T00:45:14.661Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/00/ab/66082639f99d7ef647a86b2ff4ca20f8ae13bd68a6237e6e166b8eb92edf/yarl-1.20.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f1f6670b9ae3daedb325fa55fbe31c22c8228f6e0b513772c2e1c623caa6ab22", size = 145054, upload-time = "2025-04-17T00:41:27.071Z" },
- { url = "https://files.pythonhosted.org/packages/3d/c2/4e78185c453c3ca02bd11c7907394d0410d26215f9e4b7378648b3522a30/yarl-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:85a231fa250dfa3308f3c7896cc007a47bc76e9e8e8595c20b7426cac4884c62", size = 96811, upload-time = "2025-04-17T00:41:30.235Z" },
- { url = "https://files.pythonhosted.org/packages/c7/45/91e31dccdcf5b7232dcace78bd51a1bb2d7b4b96c65eece0078b620587d1/yarl-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a06701b647c9939d7019acdfa7ebbfbb78ba6aa05985bb195ad716ea759a569", size = 94566, upload-time = "2025-04-17T00:41:32.023Z" },
- { url = "https://files.pythonhosted.org/packages/c8/21/e0aa650bcee881fb804331faa2c0f9a5d6be7609970b2b6e3cdd414e174b/yarl-1.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7595498d085becc8fb9203aa314b136ab0516c7abd97e7d74f7bb4eb95042abe", size = 327297, upload-time = "2025-04-17T00:41:34.03Z" },
- { url = "https://files.pythonhosted.org/packages/1a/a4/58f10870f5c17595c5a37da4c6a0b321589b7d7976e10570088d445d0f47/yarl-1.20.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af5607159085dcdb055d5678fc2d34949bd75ae6ea6b4381e784bbab1c3aa195", size = 323578, upload-time = "2025-04-17T00:41:36.492Z" },
- { url = "https://files.pythonhosted.org/packages/07/df/2506b1382cc0c4bb0d22a535dc3e7ccd53da9a59b411079013a7904ac35c/yarl-1.20.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:95b50910e496567434cb77a577493c26bce0f31c8a305135f3bda6a2483b8e10", size = 343212, upload-time = "2025-04-17T00:41:38.396Z" },
- { url = "https://files.pythonhosted.org/packages/ba/4a/d1c901d0e2158ad06bb0b9a92473e32d992f98673b93c8a06293e091bab0/yarl-1.20.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b594113a301ad537766b4e16a5a6750fcbb1497dcc1bc8a4daae889e6402a634", size = 337956, upload-time = "2025-04-17T00:41:40.519Z" },
- { url = "https://files.pythonhosted.org/packages/8b/fd/10fcf7d86f49b1a11096d6846257485ef32e3d3d322e8a7fdea5b127880c/yarl-1.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:083ce0393ea173cd37834eb84df15b6853b555d20c52703e21fbababa8c129d2", size = 333889, upload-time = "2025-04-17T00:41:42.437Z" },
- { url = "https://files.pythonhosted.org/packages/e2/cd/bae926a25154ba31c5fd15f2aa6e50a545c840e08d85e2e2e0807197946b/yarl-1.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f1a350a652bbbe12f666109fbddfdf049b3ff43696d18c9ab1531fbba1c977a", size = 322282, upload-time = "2025-04-17T00:41:44.641Z" },
- { url = "https://files.pythonhosted.org/packages/e2/c6/c3ac3597dfde746c63c637c5422cf3954ebf622a8de7f09892d20a68900d/yarl-1.20.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fb0caeac4a164aadce342f1597297ec0ce261ec4532bbc5a9ca8da5622f53867", size = 336270, upload-time = "2025-04-17T00:41:46.812Z" },
- { url = "https://files.pythonhosted.org/packages/dd/42/417fd7b8da5846def29712370ea8916a4be2553de42a2c969815153717be/yarl-1.20.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d88cc43e923f324203f6ec14434fa33b85c06d18d59c167a0637164863b8e995", size = 335500, upload-time = "2025-04-17T00:41:48.896Z" },
- { url = "https://files.pythonhosted.org/packages/37/aa/c2339683f8f05f4be16831b6ad58d04406cf1c7730e48a12f755da9f5ac5/yarl-1.20.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e52d6ed9ea8fd3abf4031325dc714aed5afcbfa19ee4a89898d663c9976eb487", size = 339672, upload-time = "2025-04-17T00:41:50.965Z" },
- { url = "https://files.pythonhosted.org/packages/be/12/ab6c4df95f00d7bc9502bf07a92d5354f11d9d3cb855222a6a8d2bd6e8da/yarl-1.20.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ce360ae48a5e9961d0c730cf891d40698a82804e85f6e74658fb175207a77cb2", size = 351840, upload-time = "2025-04-17T00:41:53.074Z" },
- { url = "https://files.pythonhosted.org/packages/83/3c/08d58c51bbd3899be3e7e83cd7a691fdcf3b9f78b8699d663ecc2c090ab7/yarl-1.20.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:06d06c9d5b5bc3eb56542ceeba6658d31f54cf401e8468512447834856fb0e61", size = 359550, upload-time = "2025-04-17T00:41:55.517Z" },
- { url = "https://files.pythonhosted.org/packages/8a/15/de7906c506f85fb476f0edac4bd74569f49e5ffdcf98e246a0313bf593b9/yarl-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c27d98f4e5c4060582f44e58309c1e55134880558f1add7a87c1bc36ecfade19", size = 351108, upload-time = "2025-04-17T00:41:57.582Z" },
- { url = "https://files.pythonhosted.org/packages/25/04/c6754f5ae2cdf057ac094ac01137c17875b629b1c29ed75354626a755375/yarl-1.20.0-cp310-cp310-win32.whl", hash = "sha256:f4d3fa9b9f013f7050326e165c3279e22850d02ae544ace285674cb6174b5d6d", size = 86733, upload-time = "2025-04-17T00:41:59.757Z" },
- { url = "https://files.pythonhosted.org/packages/db/1f/5c1952f3d983ac3f5fb079b5b13b62728f8a73fd27d03e1cef7e476addff/yarl-1.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc906b636239631d42eb8a07df8359905da02704a868983265603887ed68c076", size = 92916, upload-time = "2025-04-17T00:42:02.177Z" },
- { url = "https://files.pythonhosted.org/packages/60/82/a59d8e21b20ffc836775fa7daedac51d16bb8f3010c4fcb495c4496aa922/yarl-1.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fdb5204d17cb32b2de2d1e21c7461cabfacf17f3645e4b9039f210c5d3378bf3", size = 145178, upload-time = "2025-04-17T00:42:04.511Z" },
- { url = "https://files.pythonhosted.org/packages/ba/81/315a3f6f95947cfbf37c92d6fbce42a1a6207b6c38e8c2b452499ec7d449/yarl-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eaddd7804d8e77d67c28d154ae5fab203163bd0998769569861258e525039d2a", size = 96859, upload-time = "2025-04-17T00:42:06.43Z" },
- { url = "https://files.pythonhosted.org/packages/ad/17/9b64e575583158551b72272a1023cdbd65af54fe13421d856b2850a6ddb7/yarl-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:634b7ba6b4a85cf67e9df7c13a7fb2e44fa37b5d34501038d174a63eaac25ee2", size = 94647, upload-time = "2025-04-17T00:42:07.976Z" },
- { url = "https://files.pythonhosted.org/packages/2c/29/8f291e7922a58a21349683f6120a85701aeefaa02e9f7c8a2dc24fe3f431/yarl-1.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d409e321e4addf7d97ee84162538c7258e53792eb7c6defd0c33647d754172e", size = 355788, upload-time = "2025-04-17T00:42:09.902Z" },
- { url = "https://files.pythonhosted.org/packages/26/6d/b4892c80b805c42c228c6d11e03cafabf81662d371b0853e7f0f513837d5/yarl-1.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ea52f7328a36960ba3231c6677380fa67811b414798a6e071c7085c57b6d20a9", size = 344613, upload-time = "2025-04-17T00:42:11.768Z" },
- { url = "https://files.pythonhosted.org/packages/d7/0e/517aa28d3f848589bae9593717b063a544b86ba0a807d943c70f48fcf3bb/yarl-1.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c8703517b924463994c344dcdf99a2d5ce9eca2b6882bb640aa555fb5efc706a", size = 370953, upload-time = "2025-04-17T00:42:13.983Z" },
- { url = "https://files.pythonhosted.org/packages/5f/9b/5bd09d2f1ad6e6f7c2beae9e50db78edd2cca4d194d227b958955573e240/yarl-1.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:077989b09ffd2f48fb2d8f6a86c5fef02f63ffe6b1dd4824c76de7bb01e4f2e2", size = 369204, upload-time = "2025-04-17T00:42:16.386Z" },
- { url = "https://files.pythonhosted.org/packages/9c/85/d793a703cf4bd0d4cd04e4b13cc3d44149470f790230430331a0c1f52df5/yarl-1.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0acfaf1da020253f3533526e8b7dd212838fdc4109959a2c53cafc6db611bff2", size = 358108, upload-time = "2025-04-17T00:42:18.622Z" },
- { url = "https://files.pythonhosted.org/packages/6f/54/b6c71e13549c1f6048fbc14ce8d930ac5fb8bafe4f1a252e621a24f3f1f9/yarl-1.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4230ac0b97ec5eeb91d96b324d66060a43fd0d2a9b603e3327ed65f084e41f8", size = 346610, upload-time = "2025-04-17T00:42:20.9Z" },
- { url = "https://files.pythonhosted.org/packages/a0/1a/d6087d58bdd0d8a2a37bbcdffac9d9721af6ebe50d85304d9f9b57dfd862/yarl-1.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a6a1e6ae21cdd84011c24c78d7a126425148b24d437b5702328e4ba640a8902", size = 365378, upload-time = "2025-04-17T00:42:22.926Z" },
- { url = "https://files.pythonhosted.org/packages/02/84/e25ddff4cbc001dbc4af76f8d41a3e23818212dd1f0a52044cbc60568872/yarl-1.20.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:86de313371ec04dd2531f30bc41a5a1a96f25a02823558ee0f2af0beaa7ca791", size = 356919, upload-time = "2025-04-17T00:42:25.145Z" },
- { url = "https://files.pythonhosted.org/packages/04/76/898ae362353bf8f64636495d222c8014c8e5267df39b1a9fe1e1572fb7d0/yarl-1.20.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dd59c9dd58ae16eaa0f48c3d0cbe6be8ab4dc7247c3ff7db678edecbaf59327f", size = 364248, upload-time = "2025-04-17T00:42:27.475Z" },
- { url = "https://files.pythonhosted.org/packages/1b/b0/9d9198d83a622f1c40fdbf7bd13b224a6979f2e1fc2cf50bfb1d8773c495/yarl-1.20.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a0bc5e05f457b7c1994cc29e83b58f540b76234ba6b9648a4971ddc7f6aa52da", size = 378418, upload-time = "2025-04-17T00:42:29.333Z" },
- { url = "https://files.pythonhosted.org/packages/c7/ce/1f50c1cc594cf5d3f5bf4a9b616fca68680deaec8ad349d928445ac52eb8/yarl-1.20.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c9471ca18e6aeb0e03276b5e9b27b14a54c052d370a9c0c04a68cefbd1455eb4", size = 383850, upload-time = "2025-04-17T00:42:31.668Z" },
- { url = "https://files.pythonhosted.org/packages/89/1e/a59253a87b35bfec1a25bb5801fb69943330b67cfd266278eb07e0609012/yarl-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40ed574b4df723583a26c04b298b283ff171bcc387bc34c2683235e2487a65a5", size = 381218, upload-time = "2025-04-17T00:42:33.523Z" },
- { url = "https://files.pythonhosted.org/packages/85/b0/26f87df2b3044b0ef1a7cf66d321102bdca091db64c5ae853fcb2171c031/yarl-1.20.0-cp311-cp311-win32.whl", hash = "sha256:db243357c6c2bf3cd7e17080034ade668d54ce304d820c2a58514a4e51d0cfd6", size = 86606, upload-time = "2025-04-17T00:42:35.873Z" },
- { url = "https://files.pythonhosted.org/packages/33/46/ca335c2e1f90446a77640a45eeb1cd8f6934f2c6e4df7db0f0f36ef9f025/yarl-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:8c12cd754d9dbd14204c328915e23b0c361b88f3cffd124129955e60a4fbfcfb", size = 93374, upload-time = "2025-04-17T00:42:37.586Z" },
- { url = "https://files.pythonhosted.org/packages/c3/e8/3efdcb83073df978bb5b1a9cc0360ce596680e6c3fac01f2a994ccbb8939/yarl-1.20.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e06b9f6cdd772f9b665e5ba8161968e11e403774114420737f7884b5bd7bdf6f", size = 147089, upload-time = "2025-04-17T00:42:39.602Z" },
- { url = "https://files.pythonhosted.org/packages/60/c3/9e776e98ea350f76f94dd80b408eaa54e5092643dbf65fd9babcffb60509/yarl-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b9ae2fbe54d859b3ade40290f60fe40e7f969d83d482e84d2c31b9bff03e359e", size = 97706, upload-time = "2025-04-17T00:42:41.469Z" },
- { url = "https://files.pythonhosted.org/packages/0c/5b/45cdfb64a3b855ce074ae607b9fc40bc82e7613b94e7612b030255c93a09/yarl-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d12b8945250d80c67688602c891237994d203d42427cb14e36d1a732eda480e", size = 95719, upload-time = "2025-04-17T00:42:43.666Z" },
- { url = "https://files.pythonhosted.org/packages/2d/4e/929633b249611eeed04e2f861a14ed001acca3ef9ec2a984a757b1515889/yarl-1.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:087e9731884621b162a3e06dc0d2d626e1542a617f65ba7cc7aeab279d55ad33", size = 343972, upload-time = "2025-04-17T00:42:45.391Z" },
- { url = "https://files.pythonhosted.org/packages/49/fd/047535d326c913f1a90407a3baf7ff535b10098611eaef2c527e32e81ca1/yarl-1.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69df35468b66c1a6e6556248e6443ef0ec5f11a7a4428cf1f6281f1879220f58", size = 339639, upload-time = "2025-04-17T00:42:47.552Z" },
- { url = "https://files.pythonhosted.org/packages/48/2f/11566f1176a78f4bafb0937c0072410b1b0d3640b297944a6a7a556e1d0b/yarl-1.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b2992fe29002fd0d4cbaea9428b09af9b8686a9024c840b8a2b8f4ea4abc16f", size = 353745, upload-time = "2025-04-17T00:42:49.406Z" },
- { url = "https://files.pythonhosted.org/packages/26/17/07dfcf034d6ae8837b33988be66045dd52f878dfb1c4e8f80a7343f677be/yarl-1.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c903e0b42aab48abfbac668b5a9d7b6938e721a6341751331bcd7553de2dcae", size = 354178, upload-time = "2025-04-17T00:42:51.588Z" },
- { url = "https://files.pythonhosted.org/packages/15/45/212604d3142d84b4065d5f8cab6582ed3d78e4cc250568ef2a36fe1cf0a5/yarl-1.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf099e2432131093cc611623e0b0bcc399b8cddd9a91eded8bfb50402ec35018", size = 349219, upload-time = "2025-04-17T00:42:53.674Z" },
- { url = "https://files.pythonhosted.org/packages/e6/e0/a10b30f294111c5f1c682461e9459935c17d467a760c21e1f7db400ff499/yarl-1.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7f62f5dc70a6c763bec9ebf922be52aa22863d9496a9a30124d65b489ea672", size = 337266, upload-time = "2025-04-17T00:42:55.49Z" },
- { url = "https://files.pythonhosted.org/packages/33/a6/6efa1d85a675d25a46a167f9f3e80104cde317dfdf7f53f112ae6b16a60a/yarl-1.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:54ac15a8b60382b2bcefd9a289ee26dc0920cf59b05368c9b2b72450751c6eb8", size = 360873, upload-time = "2025-04-17T00:42:57.895Z" },
- { url = "https://files.pythonhosted.org/packages/77/67/c8ab718cb98dfa2ae9ba0f97bf3cbb7d45d37f13fe1fbad25ac92940954e/yarl-1.20.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:25b3bc0763a7aca16a0f1b5e8ef0f23829df11fb539a1b70476dcab28bd83da7", size = 360524, upload-time = "2025-04-17T00:43:00.094Z" },
- { url = "https://files.pythonhosted.org/packages/bd/e8/c3f18660cea1bc73d9f8a2b3ef423def8dadbbae6c4afabdb920b73e0ead/yarl-1.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b2586e36dc070fc8fad6270f93242124df68b379c3a251af534030a4a33ef594", size = 365370, upload-time = "2025-04-17T00:43:02.242Z" },
- { url = "https://files.pythonhosted.org/packages/c9/99/33f3b97b065e62ff2d52817155a89cfa030a1a9b43fee7843ef560ad9603/yarl-1.20.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:866349da9d8c5290cfefb7fcc47721e94de3f315433613e01b435473be63daa6", size = 373297, upload-time = "2025-04-17T00:43:04.189Z" },
- { url = "https://files.pythonhosted.org/packages/3d/89/7519e79e264a5f08653d2446b26d4724b01198a93a74d2e259291d538ab1/yarl-1.20.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:33bb660b390a0554d41f8ebec5cd4475502d84104b27e9b42f5321c5192bfcd1", size = 378771, upload-time = "2025-04-17T00:43:06.609Z" },
- { url = "https://files.pythonhosted.org/packages/3a/58/6c460bbb884abd2917c3eef6f663a4a873f8dc6f498561fc0ad92231c113/yarl-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737e9f171e5a07031cbee5e9180f6ce21a6c599b9d4b2c24d35df20a52fabf4b", size = 375000, upload-time = "2025-04-17T00:43:09.01Z" },
- { url = "https://files.pythonhosted.org/packages/3b/2a/dd7ed1aa23fea996834278d7ff178f215b24324ee527df53d45e34d21d28/yarl-1.20.0-cp312-cp312-win32.whl", hash = "sha256:839de4c574169b6598d47ad61534e6981979ca2c820ccb77bf70f4311dd2cc64", size = 86355, upload-time = "2025-04-17T00:43:11.311Z" },
- { url = "https://files.pythonhosted.org/packages/ca/c6/333fe0338305c0ac1c16d5aa7cc4841208d3252bbe62172e0051006b5445/yarl-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:3d7dbbe44b443b0c4aa0971cb07dcb2c2060e4a9bf8d1301140a33a93c98e18c", size = 92904, upload-time = "2025-04-17T00:43:13.087Z" },
- { url = "https://files.pythonhosted.org/packages/0f/6f/514c9bff2900c22a4f10e06297714dbaf98707143b37ff0bcba65a956221/yarl-1.20.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2137810a20b933b1b1b7e5cf06a64c3ed3b4747b0e5d79c9447c00db0e2f752f", size = 145030, upload-time = "2025-04-17T00:43:15.083Z" },
- { url = "https://files.pythonhosted.org/packages/4e/9d/f88da3fa319b8c9c813389bfb3463e8d777c62654c7168e580a13fadff05/yarl-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:447c5eadd750db8389804030d15f43d30435ed47af1313303ed82a62388176d3", size = 96894, upload-time = "2025-04-17T00:43:17.372Z" },
- { url = "https://files.pythonhosted.org/packages/cd/57/92e83538580a6968b2451d6c89c5579938a7309d4785748e8ad42ddafdce/yarl-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42fbe577272c203528d402eec8bf4b2d14fd49ecfec92272334270b850e9cd7d", size = 94457, upload-time = "2025-04-17T00:43:19.431Z" },
- { url = "https://files.pythonhosted.org/packages/e9/ee/7ee43bd4cf82dddd5da97fcaddb6fa541ab81f3ed564c42f146c83ae17ce/yarl-1.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18e321617de4ab170226cd15006a565d0fa0d908f11f724a2c9142d6b2812ab0", size = 343070, upload-time = "2025-04-17T00:43:21.426Z" },
- { url = "https://files.pythonhosted.org/packages/4a/12/b5eccd1109e2097bcc494ba7dc5de156e41cf8309fab437ebb7c2b296ce3/yarl-1.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4345f58719825bba29895011e8e3b545e6e00257abb984f9f27fe923afca2501", size = 337739, upload-time = "2025-04-17T00:43:23.634Z" },
- { url = "https://files.pythonhosted.org/packages/7d/6b/0eade8e49af9fc2585552f63c76fa59ef469c724cc05b29519b19aa3a6d5/yarl-1.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d9b980d7234614bc4674468ab173ed77d678349c860c3af83b1fffb6a837ddc", size = 351338, upload-time = "2025-04-17T00:43:25.695Z" },
- { url = "https://files.pythonhosted.org/packages/45/cb/aaaa75d30087b5183c7b8a07b4fb16ae0682dd149a1719b3a28f54061754/yarl-1.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af4baa8a445977831cbaa91a9a84cc09debb10bc8391f128da2f7bd070fc351d", size = 353636, upload-time = "2025-04-17T00:43:27.876Z" },
- { url = "https://files.pythonhosted.org/packages/98/9d/d9cb39ec68a91ba6e66fa86d97003f58570327d6713833edf7ad6ce9dde5/yarl-1.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:123393db7420e71d6ce40d24885a9e65eb1edefc7a5228db2d62bcab3386a5c0", size = 348061, upload-time = "2025-04-17T00:43:29.788Z" },
- { url = "https://files.pythonhosted.org/packages/72/6b/103940aae893d0cc770b4c36ce80e2ed86fcb863d48ea80a752b8bda9303/yarl-1.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab47acc9332f3de1b39e9b702d9c916af7f02656b2a86a474d9db4e53ef8fd7a", size = 334150, upload-time = "2025-04-17T00:43:31.742Z" },
- { url = "https://files.pythonhosted.org/packages/ef/b2/986bd82aa222c3e6b211a69c9081ba46484cffa9fab2a5235e8d18ca7a27/yarl-1.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4a34c52ed158f89876cba9c600b2c964dfc1ca52ba7b3ab6deb722d1d8be6df2", size = 362207, upload-time = "2025-04-17T00:43:34.099Z" },
- { url = "https://files.pythonhosted.org/packages/14/7c/63f5922437b873795d9422cbe7eb2509d4b540c37ae5548a4bb68fd2c546/yarl-1.20.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:04d8cfb12714158abf2618f792c77bc5c3d8c5f37353e79509608be4f18705c9", size = 361277, upload-time = "2025-04-17T00:43:36.202Z" },
- { url = "https://files.pythonhosted.org/packages/81/83/450938cccf732466953406570bdb42c62b5ffb0ac7ac75a1f267773ab5c8/yarl-1.20.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7dc63ad0d541c38b6ae2255aaa794434293964677d5c1ec5d0116b0e308031f5", size = 364990, upload-time = "2025-04-17T00:43:38.551Z" },
- { url = "https://files.pythonhosted.org/packages/b4/de/af47d3a47e4a833693b9ec8e87debb20f09d9fdc9139b207b09a3e6cbd5a/yarl-1.20.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d02b591a64e4e6ca18c5e3d925f11b559c763b950184a64cf47d74d7e41877", size = 374684, upload-time = "2025-04-17T00:43:40.481Z" },
- { url = "https://files.pythonhosted.org/packages/62/0b/078bcc2d539f1faffdc7d32cb29a2d7caa65f1a6f7e40795d8485db21851/yarl-1.20.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:95fc9876f917cac7f757df80a5dda9de59d423568460fe75d128c813b9af558e", size = 382599, upload-time = "2025-04-17T00:43:42.463Z" },
- { url = "https://files.pythonhosted.org/packages/74/a9/4fdb1a7899f1fb47fd1371e7ba9e94bff73439ce87099d5dd26d285fffe0/yarl-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bb769ae5760cd1c6a712135ee7915f9d43f11d9ef769cb3f75a23e398a92d384", size = 378573, upload-time = "2025-04-17T00:43:44.797Z" },
- { url = "https://files.pythonhosted.org/packages/fd/be/29f5156b7a319e4d2e5b51ce622b4dfb3aa8d8204cd2a8a339340fbfad40/yarl-1.20.0-cp313-cp313-win32.whl", hash = "sha256:70e0c580a0292c7414a1cead1e076c9786f685c1fc4757573d2967689b370e62", size = 86051, upload-time = "2025-04-17T00:43:47.076Z" },
- { url = "https://files.pythonhosted.org/packages/52/56/05fa52c32c301da77ec0b5f63d2d9605946fe29defacb2a7ebd473c23b81/yarl-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:4c43030e4b0af775a85be1fa0433119b1565673266a70bf87ef68a9d5ba3174c", size = 92742, upload-time = "2025-04-17T00:43:49.193Z" },
- { url = "https://files.pythonhosted.org/packages/d4/2f/422546794196519152fc2e2f475f0e1d4d094a11995c81a465faf5673ffd/yarl-1.20.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b6c4c3d0d6a0ae9b281e492b1465c72de433b782e6b5001c8e7249e085b69051", size = 163575, upload-time = "2025-04-17T00:43:51.533Z" },
- { url = "https://files.pythonhosted.org/packages/90/fc/67c64ddab6c0b4a169d03c637fb2d2a212b536e1989dec8e7e2c92211b7f/yarl-1.20.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8681700f4e4df891eafa4f69a439a6e7d480d64e52bf460918f58e443bd3da7d", size = 106121, upload-time = "2025-04-17T00:43:53.506Z" },
- { url = "https://files.pythonhosted.org/packages/6d/00/29366b9eba7b6f6baed7d749f12add209b987c4cfbfa418404dbadc0f97c/yarl-1.20.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:84aeb556cb06c00652dbf87c17838eb6d92cfd317799a8092cee0e570ee11229", size = 103815, upload-time = "2025-04-17T00:43:55.41Z" },
- { url = "https://files.pythonhosted.org/packages/28/f4/a2a4c967c8323c03689383dff73396281ced3b35d0ed140580825c826af7/yarl-1.20.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f166eafa78810ddb383e930d62e623d288fb04ec566d1b4790099ae0f31485f1", size = 408231, upload-time = "2025-04-17T00:43:57.825Z" },
- { url = "https://files.pythonhosted.org/packages/0f/a1/66f7ffc0915877d726b70cc7a896ac30b6ac5d1d2760613603b022173635/yarl-1.20.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5d3d6d14754aefc7a458261027a562f024d4f6b8a798adb472277f675857b1eb", size = 390221, upload-time = "2025-04-17T00:44:00.526Z" },
- { url = "https://files.pythonhosted.org/packages/41/15/cc248f0504610283271615e85bf38bc014224122498c2016d13a3a1b8426/yarl-1.20.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a8f64df8ed5d04c51260dbae3cc82e5649834eebea9eadfd829837b8093eb00", size = 411400, upload-time = "2025-04-17T00:44:02.853Z" },
- { url = "https://files.pythonhosted.org/packages/5c/af/f0823d7e092bfb97d24fce6c7269d67fcd1aefade97d0a8189c4452e4d5e/yarl-1.20.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d9949eaf05b4d30e93e4034a7790634bbb41b8be2d07edd26754f2e38e491de", size = 411714, upload-time = "2025-04-17T00:44:04.904Z" },
- { url = "https://files.pythonhosted.org/packages/83/70/be418329eae64b9f1b20ecdaac75d53aef098797d4c2299d82ae6f8e4663/yarl-1.20.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c366b254082d21cc4f08f522ac201d0d83a8b8447ab562732931d31d80eb2a5", size = 404279, upload-time = "2025-04-17T00:44:07.721Z" },
- { url = "https://files.pythonhosted.org/packages/19/f5/52e02f0075f65b4914eb890eea1ba97e6fd91dd821cc33a623aa707b2f67/yarl-1.20.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91bc450c80a2e9685b10e34e41aef3d44ddf99b3a498717938926d05ca493f6a", size = 384044, upload-time = "2025-04-17T00:44:09.708Z" },
- { url = "https://files.pythonhosted.org/packages/6a/36/b0fa25226b03d3f769c68d46170b3e92b00ab3853d73127273ba22474697/yarl-1.20.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c2aa4387de4bc3a5fe158080757748d16567119bef215bec643716b4fbf53f9", size = 416236, upload-time = "2025-04-17T00:44:11.734Z" },
- { url = "https://files.pythonhosted.org/packages/cb/3a/54c828dd35f6831dfdd5a79e6c6b4302ae2c5feca24232a83cb75132b205/yarl-1.20.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:d2cbca6760a541189cf87ee54ff891e1d9ea6406079c66341008f7ef6ab61145", size = 402034, upload-time = "2025-04-17T00:44:13.975Z" },
- { url = "https://files.pythonhosted.org/packages/10/97/c7bf5fba488f7e049f9ad69c1b8fdfe3daa2e8916b3d321aa049e361a55a/yarl-1.20.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:798a5074e656f06b9fad1a162be5a32da45237ce19d07884d0b67a0aa9d5fdda", size = 407943, upload-time = "2025-04-17T00:44:16.052Z" },
- { url = "https://files.pythonhosted.org/packages/fd/a4/022d2555c1e8fcff08ad7f0f43e4df3aba34f135bff04dd35d5526ce54ab/yarl-1.20.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f106e75c454288472dbe615accef8248c686958c2e7dd3b8d8ee2669770d020f", size = 423058, upload-time = "2025-04-17T00:44:18.547Z" },
- { url = "https://files.pythonhosted.org/packages/4c/f6/0873a05563e5df29ccf35345a6ae0ac9e66588b41fdb7043a65848f03139/yarl-1.20.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3b60a86551669c23dc5445010534d2c5d8a4e012163218fc9114e857c0586fdd", size = 423792, upload-time = "2025-04-17T00:44:20.639Z" },
- { url = "https://files.pythonhosted.org/packages/9e/35/43fbbd082708fa42e923f314c24f8277a28483d219e049552e5007a9aaca/yarl-1.20.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e429857e341d5e8e15806118e0294f8073ba9c4580637e59ab7b238afca836f", size = 422242, upload-time = "2025-04-17T00:44:22.851Z" },
- { url = "https://files.pythonhosted.org/packages/ed/f7/f0f2500cf0c469beb2050b522c7815c575811627e6d3eb9ec7550ddd0bfe/yarl-1.20.0-cp313-cp313t-win32.whl", hash = "sha256:65a4053580fe88a63e8e4056b427224cd01edfb5f951498bfefca4052f0ce0ac", size = 93816, upload-time = "2025-04-17T00:44:25.491Z" },
- { url = "https://files.pythonhosted.org/packages/3f/93/f73b61353b2a699d489e782c3f5998b59f974ec3156a2050a52dfd7e8946/yarl-1.20.0-cp313-cp313t-win_amd64.whl", hash = "sha256:53b2da3a6ca0a541c1ae799c349788d480e5144cac47dba0266c7cb6c76151fe", size = 101093, upload-time = "2025-04-17T00:44:27.418Z" },
- { url = "https://files.pythonhosted.org/packages/bc/95/3d22e1d2fa6dce3670d820a859f4fc5526400c58019650d2da4e19b9924d/yarl-1.20.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:119bca25e63a7725b0c9d20ac67ca6d98fa40e5a894bd5d4686010ff73397914", size = 146680, upload-time = "2025-04-17T00:44:29.739Z" },
- { url = "https://files.pythonhosted.org/packages/12/43/37f2d17e0b82d4f01b2da1fe53a19ff95be6d7d9902cad11d3ebbef5bc9d/yarl-1.20.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:35d20fb919546995f1d8c9e41f485febd266f60e55383090010f272aca93edcc", size = 97707, upload-time = "2025-04-17T00:44:32.288Z" },
- { url = "https://files.pythonhosted.org/packages/8c/3e/665501121ba7c712a0f1b58d8ee01d7633096671fbeec4cf3dc4e4357a95/yarl-1.20.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:484e7a08f72683c0f160270566b4395ea5412b4359772b98659921411d32ad26", size = 95385, upload-time = "2025-04-17T00:44:34.472Z" },
- { url = "https://files.pythonhosted.org/packages/bf/8d/48edf4d49ca38e5229faf793276bdd6f01704740dcf519cf1d282acac6c6/yarl-1.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d8a3d54a090e0fff5837cd3cc305dd8a07d3435a088ddb1f65e33b322f66a94", size = 332687, upload-time = "2025-04-17T00:44:36.855Z" },
- { url = "https://files.pythonhosted.org/packages/e0/c1/112c516bead873c83abe30e08143714d702d1fffdfed43dc103312b81666/yarl-1.20.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f0cf05ae2d3d87a8c9022f3885ac6dea2b751aefd66a4f200e408a61ae9b7f0d", size = 325390, upload-time = "2025-04-17T00:44:38.956Z" },
- { url = "https://files.pythonhosted.org/packages/0b/4c/07aef11f7f23a41049eb0b3b357ceb32bd9798f62042858e0168be9f6f49/yarl-1.20.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a884b8974729e3899d9287df46f015ce53f7282d8d3340fa0ed57536b440621c", size = 348497, upload-time = "2025-04-17T00:44:42.453Z" },
- { url = "https://files.pythonhosted.org/packages/56/d9/00d5525a2c5e5c66967eaa03866bef6317da4b129ae016582c6641826974/yarl-1.20.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f8d8aa8dd89ffb9a831fedbcb27d00ffd9f4842107d52dc9d57e64cb34073d5c", size = 343670, upload-time = "2025-04-17T00:44:44.822Z" },
- { url = "https://files.pythonhosted.org/packages/e8/7c/2fc733090c6fce82ea5c50f431e70f5dff196d7b54da93b9d6e801031dd2/yarl-1.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4e88d6c3c8672f45a30867817e4537df1bbc6f882a91581faf1f6d9f0f1b5a", size = 335738, upload-time = "2025-04-17T00:44:47.352Z" },
- { url = "https://files.pythonhosted.org/packages/4b/ce/6b22de535b7bc7b19f3cf23c4e744cd2368fa11a0c8f218dfd2ef46b6c3a/yarl-1.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdb77efde644d6f1ad27be8a5d67c10b7f769804fff7a966ccb1da5a4de4b656", size = 328203, upload-time = "2025-04-17T00:44:49.728Z" },
- { url = "https://files.pythonhosted.org/packages/6b/c8/3fc10db34e731a426baaff348aa1b2c0eb9cb93ff723af4e930e767c058e/yarl-1.20.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4ba5e59f14bfe8d261a654278a0f6364feef64a794bd456a8c9e823071e5061c", size = 341922, upload-time = "2025-04-17T00:44:52.233Z" },
- { url = "https://files.pythonhosted.org/packages/37/59/f607a63c24b31c66cf288cb819d8dbcac2bd9ec90f39bd03986f33a866b3/yarl-1.20.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:d0bf955b96ea44ad914bc792c26a0edcd71b4668b93cbcd60f5b0aeaaed06c64", size = 338163, upload-time = "2025-04-17T00:44:54.511Z" },
- { url = "https://files.pythonhosted.org/packages/01/b2/5fd461fe8ab3bb788e19ef6c35a3453f44a5c0d6973f847a08060c4d6183/yarl-1.20.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:27359776bc359ee6eaefe40cb19060238f31228799e43ebd3884e9c589e63b20", size = 343096, upload-time = "2025-04-17T00:44:56.789Z" },
- { url = "https://files.pythonhosted.org/packages/71/d3/7102efd34ed22e6839361f30a27bdad341c0a01f66fcbf09822a1d90b853/yarl-1.20.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:04d9c7a1dc0a26efb33e1acb56c8849bd57a693b85f44774356c92d610369efa", size = 358520, upload-time = "2025-04-17T00:44:58.974Z" },
- { url = "https://files.pythonhosted.org/packages/c0/ab/754b60a5c8be8abaa746543555612b2205ba60c194fc3a0547a34e0b6a53/yarl-1.20.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:faa709b66ae0e24c8e5134033187a972d849d87ed0a12a0366bedcc6b5dc14a5", size = 359635, upload-time = "2025-04-17T00:45:01.457Z" },
- { url = "https://files.pythonhosted.org/packages/e0/d5/369f994369a7233fcd81f642553062d4f6c657a93069b58258b9046bb87d/yarl-1.20.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:44869ee8538208fe5d9342ed62c11cc6a7a1af1b3d0bb79bb795101b6e77f6e0", size = 353906, upload-time = "2025-04-17T00:45:04.217Z" },
- { url = "https://files.pythonhosted.org/packages/1b/59/c7f929d7cd7c1f0c918c38aca06d07cac2e4f3577a95fe3a836b3079a3ca/yarl-1.20.0-cp39-cp39-win32.whl", hash = "sha256:b7fa0cb9fd27ffb1211cde944b41f5c67ab1c13a13ebafe470b1e206b8459da8", size = 87243, upload-time = "2025-04-17T00:45:06.961Z" },
- { url = "https://files.pythonhosted.org/packages/1c/bc/80f16fc58cb3b61b15450eaf6c874d9c984c96453d9024b9d0aa4655dac9/yarl-1.20.0-cp39-cp39-win_amd64.whl", hash = "sha256:d4fad6e5189c847820288286732075f213eabf81be4d08d6cc309912e62be5b7", size = 93457, upload-time = "2025-04-17T00:45:09.651Z" },
- { url = "https://files.pythonhosted.org/packages/ea/1f/70c57b3d7278e94ed22d85e09685d3f0a38ebdd8c5c73b65ba4c0d0fe002/yarl-1.20.0-py3-none-any.whl", hash = "sha256:5d0fe6af927a47a230f31e6004621fd0959eaa915fc62acfafa67ff7229a3124", size = 46124, upload-time = "2025-04-17T00:45:12.199Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/43/a2204825342f37c337f5edb6637040fa14e365b2fcc2346960201d457579/yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", size = 140517, upload-time = "2025-10-06T14:08:42.494Z" },
+ { url = "https://files.pythonhosted.org/packages/44/6f/674f3e6f02266428c56f704cd2501c22f78e8b2eeb23f153117cc86fb28a/yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", size = 93495, upload-time = "2025-10-06T14:08:46.2Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/12/5b274d8a0f30c07b91b2f02cba69152600b47830fcfb465c108880fcee9c/yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", size = 94400, upload-time = "2025-10-06T14:08:47.855Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/7f/df1b6949b1fa1aa9ff6de6e2631876ad4b73c4437822026e85d8acb56bb1/yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", size = 347545, upload-time = "2025-10-06T14:08:49.683Z" },
+ { url = "https://files.pythonhosted.org/packages/84/09/f92ed93bd6cd77872ab6c3462df45ca45cd058d8f1d0c9b4f54c1704429f/yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", size = 319598, upload-time = "2025-10-06T14:08:51.215Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/97/ac3f3feae7d522cf7ccec3d340bb0b2b61c56cb9767923df62a135092c6b/yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", size = 363893, upload-time = "2025-10-06T14:08:53.144Z" },
+ { url = "https://files.pythonhosted.org/packages/06/49/f3219097403b9c84a4d079b1d7bda62dd9b86d0d6e4428c02d46ab2c77fc/yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", size = 371240, upload-time = "2025-10-06T14:08:55.036Z" },
+ { url = "https://files.pythonhosted.org/packages/35/9f/06b765d45c0e44e8ecf0fe15c9eacbbde342bb5b7561c46944f107bfb6c3/yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", size = 346965, upload-time = "2025-10-06T14:08:56.722Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/69/599e7cea8d0fcb1694323b0db0dda317fa3162f7b90166faddecf532166f/yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", size = 342026, upload-time = "2025-10-06T14:08:58.563Z" },
+ { url = "https://files.pythonhosted.org/packages/95/6f/9dfd12c8bc90fea9eab39832ee32ea48f8e53d1256252a77b710c065c89f/yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", size = 335637, upload-time = "2025-10-06T14:09:00.506Z" },
+ { url = "https://files.pythonhosted.org/packages/57/2e/34c5b4eb9b07e16e873db5b182c71e5f06f9b5af388cdaa97736d79dd9a6/yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", size = 359082, upload-time = "2025-10-06T14:09:01.936Z" },
+ { url = "https://files.pythonhosted.org/packages/31/71/fa7e10fb772d273aa1f096ecb8ab8594117822f683bab7d2c5a89914c92a/yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", size = 357811, upload-time = "2025-10-06T14:09:03.445Z" },
+ { url = "https://files.pythonhosted.org/packages/26/da/11374c04e8e1184a6a03cf9c8f5688d3e5cec83ed6f31ad3481b3207f709/yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", size = 351223, upload-time = "2025-10-06T14:09:05.401Z" },
+ { url = "https://files.pythonhosted.org/packages/82/8f/e2d01f161b0c034a30410e375e191a5d27608c1f8693bab1a08b089ca096/yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", size = 82118, upload-time = "2025-10-06T14:09:11.148Z" },
+ { url = "https://files.pythonhosted.org/packages/62/46/94c76196642dbeae634c7a61ba3da88cd77bed875bf6e4a8bed037505aa6/yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", size = 86852, upload-time = "2025-10-06T14:09:12.958Z" },
+ { url = "https://files.pythonhosted.org/packages/af/af/7df4f179d3b1a6dcb9a4bd2ffbc67642746fcafdb62580e66876ce83fff4/yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", size = 82012, upload-time = "2025-10-06T14:09:14.664Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" },
+ { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" },
+ { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" },
+ { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" },
+ { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" },
+ { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" },
+ { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" },
+ { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" },
+ { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" },
+ { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" },
+ { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" },
+ { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" },
+ { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" },
+ { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" },
+ { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" },
+ { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" },
+ { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" },
+ { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" },
+ { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" },
+ { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" },
+ { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" },
+ { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" },
+ { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" },
+ { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" },
+ { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" },
+ { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" },
+ { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" },
+ { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" },
+ { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" },
+ { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" },
+ { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" },
+ { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" },
+ { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" },
+ { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" },
+ { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" },
+ { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" },
+ { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" },
+ { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" },
+ { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" },
+ { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" },
+ { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" },
+ { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" },
+ { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" },
+ { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" },
+ { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" },
+ { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" },
+ { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" },
+ { url = "https://files.pythonhosted.org/packages/94/fd/6480106702a79bcceda5fd9c63cb19a04a6506bd5ce7fd8d9b63742f0021/yarl-1.22.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3aa27acb6de7a23785d81557577491f6c38a5209a254d1191519d07d8fe51748", size = 141301, upload-time = "2025-10-06T14:12:19.01Z" },
+ { url = "https://files.pythonhosted.org/packages/42/e1/6d95d21b17a93e793e4ec420a925fe1f6a9342338ca7a563ed21129c0990/yarl-1.22.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:af74f05666a5e531289cb1cc9c883d1de2088b8e5b4de48004e5ca8a830ac859", size = 93864, upload-time = "2025-10-06T14:12:21.05Z" },
+ { url = "https://files.pythonhosted.org/packages/32/58/b8055273c203968e89808413ea4c984988b6649baabf10f4522e67c22d2f/yarl-1.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:62441e55958977b8167b2709c164c91a6363e25da322d87ae6dd9c6019ceecf9", size = 94706, upload-time = "2025-10-06T14:12:23.287Z" },
+ { url = "https://files.pythonhosted.org/packages/18/91/d7bfbc28a88c2895ecd0da6a874def0c147de78afc52c773c28e1aa233a3/yarl-1.22.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b580e71cac3f8113d3135888770903eaf2f507e9421e5697d6ee6d8cd1c7f054", size = 347100, upload-time = "2025-10-06T14:12:28.527Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/e8/37a1e7b99721c0564b1fc7b0a4d1f595ef6fb8060d82ca61775b644185f7/yarl-1.22.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e81fda2fb4a07eda1a2252b216aa0df23ebcd4d584894e9612e80999a78fd95b", size = 318902, upload-time = "2025-10-06T14:12:30.528Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/ef/34724449d7ef2db4f22df644f2dac0b8a275d20f585e526937b3ae47b02d/yarl-1.22.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99b6fc1d55782461b78221e95fc357b47ad98b041e8e20f47c1411d0aacddc60", size = 363302, upload-time = "2025-10-06T14:12:32.295Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/04/88a39a5dad39889f192cce8d66cc4c58dbeca983e83f9b6bf23822a7ed91/yarl-1.22.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:088e4e08f033db4be2ccd1f34cf29fe994772fb54cfe004bbf54db320af56890", size = 370816, upload-time = "2025-10-06T14:12:34.01Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/1f/5e895e547129413f56c76be2c3ce4b96c797d2d0ff3e16a817d9269b12e6/yarl-1.22.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4e1f6f0b4da23e61188676e3ed027ef0baa833a2e633c29ff8530800edccba", size = 346465, upload-time = "2025-10-06T14:12:35.977Z" },
+ { url = "https://files.pythonhosted.org/packages/11/13/a750e9fd6f9cc9ed3a52a70fe58ffe505322f0efe0d48e1fd9ffe53281f5/yarl-1.22.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:84fc3ec96fce86ce5aa305eb4aa9358279d1aa644b71fab7b8ed33fe3ba1a7ca", size = 341506, upload-time = "2025-10-06T14:12:37.788Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/67/bb6024de76e7186611ebe626aec5b71a2d2ecf9453e795f2dbd80614784c/yarl-1.22.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5dbeefd6ca588b33576a01b0ad58aa934bc1b41ef89dee505bf2932b22ddffba", size = 335030, upload-time = "2025-10-06T14:12:39.775Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/be/50b38447fd94a7992996a62b8b463d0579323fcfc08c61bdba949eef8a5d/yarl-1.22.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14291620375b1060613f4aab9ebf21850058b6b1b438f386cc814813d901c60b", size = 358560, upload-time = "2025-10-06T14:12:41.547Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/89/c020b6f547578c4e3dbb6335bf918f26e2f34ad0d1e515d72fd33ac0c635/yarl-1.22.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a4fcfc8eb2c34148c118dfa02e6427ca278bfd0f3df7c5f99e33d2c0e81eae3e", size = 357290, upload-time = "2025-10-06T14:12:43.861Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/52/c49a619ee35a402fa3a7019a4fa8d26878fec0d1243f6968bbf516789578/yarl-1.22.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:029866bde8d7b0878b9c160e72305bbf0a7342bcd20b9999381704ae03308dc8", size = 350700, upload-time = "2025-10-06T14:12:46.868Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/c9/f5042d87777bf6968435f04a2bbb15466b2f142e6e47fa4f34d1a3f32f0c/yarl-1.22.0-cp39-cp39-win32.whl", hash = "sha256:4dcc74149ccc8bba31ce1944acee24813e93cfdee2acda3c172df844948ddf7b", size = 82323, upload-time = "2025-10-06T14:12:48.633Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/58/d00f7cad9eba20c4eefac2682f34661d1d1b3a942fc0092eb60e78cfb733/yarl-1.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:10619d9fdee46d20edc49d3479e2f8269d0779f1b031e6f7c2aa1c76be04b7ed", size = 87145, upload-time = "2025-10-06T14:12:50.241Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/a3/70904f365080780d38b919edd42d224b8c4ce224a86950d2eaa2a24366ad/yarl-1.22.0-cp39-cp39-win_arm64.whl", hash = "sha256:dd7afd3f8b0bfb4e0d9fc3c31bfe8a4ec7debe124cfd90619305def3c8ca8cd2", size = 82173, upload-time = "2025-10-06T14:12:51.869Z" },
+ { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
]
[[package]]
name = "zipp"
-version = "3.22.0"
+version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/12/b6/7b3d16792fdf94f146bed92be90b4eb4563569eca91513c8609aebf0c167/zipp-3.22.0.tar.gz", hash = "sha256:dd2f28c3ce4bc67507bfd3781d21b7bb2be31103b51a4553ad7d90b84e57ace5", size = 25257, upload-time = "2025-05-26T14:46:32.217Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ad/da/f64669af4cae46f17b90798a827519ce3737d31dbafad65d391e49643dc4/zipp-3.22.0-py3-none-any.whl", hash = "sha256:fe208f65f2aca48b81f9e6fd8cf7b8b32c26375266b009b413d45306b6148343", size = 9796, upload-time = "2025-05-26T14:46:30.775Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
]