diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..433bd63 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "10x-mvp-tracker": { + "command": "npx", + "args": ["-y", "@przeprogramowani/10x-mvp-tracker@latest"], + "transport": "stdio" + } + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..59cd293 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,38 @@ +.git +.gitignore +.github +.cursor + +__pycache__/ +*.py[cod] +*.pyo +*.pyd + +.venv/ +venv/ +env/ + +.pytest_cache/ +htmlcov/ +.coverage +.coverage.* +junit.xml + +# Local/dev config & secrets +.env +.env.* +config.yaml + +# Runtime/generated artifacts +app/celerybeat-schedule +app/celerybeat-schedule.* +**/celerybeat-schedule +**/celerybeat-schedule.* + +# Media/static outputs (generated or mounted at runtime) +media/ +static/ +staticfiles/ +app/media/ +app/static/ +app/staticfiles/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..93eb12f --- /dev/null +++ b/.env.example @@ -0,0 +1,71 @@ +# Django Settings +# Environment variable names can match YAML paths exactly (dot-separated lowercase) +# or use traditional format (uppercase with underscores) +# Both formats work: 'django.secret_key' OR 'DJANGO_SECRET_KEY' +django.secret_key=your-secret-key-here-change-in-production +django.debug=True +django.allowed_hosts=localhost,127.0.0.1 +django.site_id=1 +# Alternative uppercase format also works: +# DJANGO_SECRET_KEY=your-secret-key-here-change-in-production +# DJANGO_DEBUG=True + +# Database Configuration +# Dot-separated format (matches YAML exactly) or uppercase underscore format +# For VPS: Use local database +database.name=gallery +database.user=postgres +database.password=postgres +database.host=db +database.port=5432 +# For Local GPU Worker: Use VPS database via WireGuard VPN +# database.host=10.0.0.1 +# database.port=5432 +# Alternative: DATABASE_NAME, DATABASE_USER, etc. also work + +# Authentication Configuration +# Dot-separated format (matches YAML exactly) +authentication.account_email_verification=none +# Options: 'mandatory', 'optional', or 'none' + +# Social Authentication - Google +# Dot-separated format preferred, uppercase underscore also works +social_auth.google.enabled=true +social_auth.google.client_id=your-google-client-id +social_auth.google.secret=your-google-secret + +# Social Authentication - Facebook +social_auth.facebook.enabled=false +social_auth.facebook.app_id=your-facebook-app-id +social_auth.facebook.app_secret=your-facebook-app-secret + +# Social Authentication - GitHub +social_auth.github.enabled=false +social_auth.github.client_id=your-github-client-id +social_auth.github.secret=your-github-secret + +# Celery Configuration +# Dot-separated format (matches YAML exactly) +# For VPS: Use local Redis +celery.broker_url=redis://redis:6379/0 +celery.result_backend=redis://redis:6379/0 +# For Local GPU Worker: Use VPS Redis via WireGuard VPN +# celery.broker_url=redis://10.0.0.1:6379/0 +# celery.result_backend=redis://10.0.0.1:6379/0 +REDIS_PORT=6379 + +# Flower Configuration (Celery Monitoring) +# Note: FLOWER_USERNAME and FLOWER_PASSWORD are still used directly (not from config.yaml) +FLOWER_PORT=5555 +FLOWER_USERNAME=admin +FLOWER_PASSWORD=change-this-password-in-production + +# SeaweedFS / S3 Configuration +# Dot-separated format (matches YAML exactly) +storage.use_s3=False +storage.s3.access_key_id= +storage.s3.secret_access_key= +storage.s3.bucket_name=gallery +storage.s3.endpoint_url=http://seaweedfs:8333 +storage.s3.region_name=us-east-1 +storage.s3.use_ssl=False diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..bf78d6e --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,72 @@ +# GitHub Actions Workflows + +This directory contains GitHub Actions workflows for CI/CD. + +## Workflows + +### `tests.yml` (Enabled) + +Runs the full test suite (unit + e2e with Playwright) on every pull request and push to main branches. + +**Features:** +- Tests on Python 3.11 and 3.12 +- **uv** for installs with dependency cache (`enable-cache: true`, keyed by `uv.lock`) +- **Playwright** for e2e: Chromium only; browser binaries cached (`~/.cache/ms-playwright`, keyed by `uv.lock`); on cache hit only OS deps are installed +- Uses SQLite (no external database required) +- Generates coverage reports +- Uploads test results as artifacts +- Optional Codecov integration + +**Triggers:** +- Pull requests to `main`, `master`, or `develop` +- Pushes to `main`, `master`, or `develop` + +### `tests.yml.disabled` (Legacy / Disabled) + +Previous test workflow (pip-based, no Playwright). Superseded by `tests.yml`. + +### `lint.yml.disabled` (Currently Disabled) + +Runs code quality checks (optional - can be enabled when you add linting tools). + +**Features:** +- Checks code formatting with `black` +- Checks import sorting with `isort` +- Runs `ruff` for linting + +**Note:** Currently set to not fail the build (`|| true`). Remove this when you're ready to enforce linting. + +## Setup + +### Codecov (Optional) + +If you want to use Codecov for coverage tracking: + +1. Sign up at [codecov.io](https://codecov.io) +2. Add your repository +3. The workflow will automatically upload coverage reports + +If you don't want Codecov, the workflow will still work - it just won't upload coverage (the step is set to `continue-on-error: true`). + +## Customization + +### Enable Lint Workflow + +To enable the lint workflow, rename `lint.yml.disabled` → `lint.yml`. + +### Add More Python Versions + +Edit `.github/workflows/tests.yml`: + +```yaml +matrix: + python-version: ["3.11", "3.12", "3.13"] +``` + +### Add PostgreSQL for Integration Tests + +Uncomment the PostgreSQL service section in `tests.yml` if you need it for integration tests. + +### Enable Linting Enforcement + +Edit `.github/workflows/lint.yml` (after enabling) and remove `|| true` from the commands to make linting failures block PRs. diff --git a/.github/workflows/lint.yml.disabled b/.github/workflows/lint.yml.disabled new file mode 100644 index 0000000..4c3f0a4 --- /dev/null +++ b/.github/workflows/lint.yml.disabled @@ -0,0 +1,44 @@ +name: Lint + +on: + pull_request: + branches: + - main + - master + - develop + push: + branches: + - main + - master + - develop + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: 'pip' + + - name: Install linting dependencies + run: | + python -m pip install --upgrade pip + pip install ruff black isort mypy + + - name: Run ruff + run: | + ruff check app/ || true + + - name: Check code formatting with black + run: | + black --check app/ || true + + - name: Check import sorting with isort + run: | + isort --check-only app/ || true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..568114f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,82 @@ +name: Tests + +on: + pull_request: + branches: + - main + - master + - develop + push: + branches: + - main + - master + - develop + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.11", "3.12"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Install dependencies + run: uv sync --locked --extra dev + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('uv.lock') }} + restore-keys: | + playwright-${{ runner.os }}- + + - name: Install Playwright Chromium + run: | + if [ "${{ steps.playwright-cache.outputs.cache-hit }}" != "true" ]; then + uv run playwright install chromium --with-deps + else + uv run playwright install-deps + fi + + - name: Run tests with pytest (unit + e2e) + env: + DJANGO_SETTINGS_MODULE: config.test_settings + run: | + uv run pytest --cov=app --cov-report=xml --cov-report=term-missing --junitxml=junit.xml -v + + - name: Upload coverage to Codecov (optional) + uses: codecov/codecov-action@v4 + if: always() + continue-on-error: true + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-${{ matrix.python-version }} + path: | + junit.xml + coverage.xml + retention-days: 30 diff --git a/.github/workflows/tests.yml.disabled b/.github/workflows/tests.yml.disabled new file mode 100644 index 0000000..f089649 --- /dev/null +++ b/.github/workflows/tests.yml.disabled @@ -0,0 +1,78 @@ +name: Tests + +on: + pull_request: + branches: + - main + - master + - develop + push: + branches: + - main + - master + - develop + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.11", "3.12"] + + services: + # PostgreSQL is not needed since tests use SQLite + # But we can add it if needed for integration tests later + # postgres: + # image: postgres:16 + # env: + # POSTGRES_PASSWORD: postgres + # POSTGRES_DB: gallery_test + # options: >- + # --health-cmd pg_isready + # --health-interval 10s + # --health-timeout 5s + # --health-retries 5 + # ports: + # - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests with pytest + env: + DJANGO_SETTINGS_MODULE: config.test_settings + run: | + pytest --cov=app --cov-report=xml --cov-report=term-missing --junitxml=junit.xml -v + + - name: Upload coverage to Codecov (optional) + uses: codecov/codecov-action@v4 + if: always() + continue-on-error: true + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-${{ matrix.python-version }} + path: | + junit.xml + coverage.xml + retention-days: 30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a727df6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv +venv/ +env/ + +# Django +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +test_db.sqlite3 # Test database (if using file-based SQLite for debugging) +/media +/staticfiles +/static + +# Environment variables +.env +.env.local +.env.*.local + +# Configuration files +config.yaml +!config.example.yaml + +# Nginx SSL (certificates and local SSL config) +nginx/ssl/ +nginx/conf.d/ssl.conf + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Docker + +# Celery beat schedule (runtime files) +celerybeat-schedule +celerybeat-schedule* +celerybeat-schedule.* +**/celerybeat-schedule +**/celerybeat-schedule.* + +# Test artifacts +.coverage +.coverage.* +coverage.xml +htmlcov/ +.pytest_cache/ +junit.xml +*.cover +.hypothesis/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c96cf9d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# Use Python 3.14 slim image +FROM python:3.14-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV DEBIAN_FRONTEND=noninteractive + +# Set work directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + postgresql-client \ + libpq-dev \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Install only dependencies from pyproject.toml (no project install; app/ copied last for cache) +COPY pyproject.toml ./ +RUN pip install --no-cache-dir --upgrade pip && \ + python3 -c "import tomllib; d=tomllib.load(open('pyproject.toml','rb')); print('\n'.join(d['project']['dependencies']))" > requirements.txt && \ + pip install --no-cache-dir -r requirements.txt && \ + rm -rf /root/.cache/pip + +# Copy Django app (manage.py, config/, gallery/ under /app) +COPY app/ . +ENV PYTHONPATH=/app + +# Collect static files +RUN python manage.py collectstatic --noinput || true + +# Create a non-root user +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app +USER appuser + +# Expose port +EXPOSE 8000 + +# Run gunicorn +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "--timeout", "120", "config.wsgi:application"] diff --git a/Dockerfile.celery b/Dockerfile.celery new file mode 100644 index 0000000..e76a5c5 --- /dev/null +++ b/Dockerfile.celery @@ -0,0 +1,79 @@ +# Dockerfile for Celery worker with NVIDIA GPU support +# For YOLO object recognition and PaddleOCR +# Optimized for Windows WSL + +# Use NVIDIA CUDA base image (no Python; we install system Python below) +# CUDA 13.0 runtime with cuDNN for PyTorch and PaddlePaddle GPU +FROM nvidia/cuda:13.0.2-cudnn-runtime-ubuntu24.04 + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV DEBIAN_FRONTEND=noninteractive +ENV NVIDIA_VISIBLE_DEVICES=all +ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility +ENV ULTRALYTICS_CACHE_DIR=/app/.ultralytics + +# Install system Python 3.12 and dependencies (Ubuntu 24.04; libgl1-mesa-glx → libgl1) +RUN apt-get update && apt-get install -y \ + python3 \ + python3-dev \ + python3-venv \ + python3-pip \ + postgresql-client \ + libpq-dev \ + gcc \ + g++ \ + libgl1 \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender-dev \ + libgomp1 \ + wget \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install uv and create venv at /opt/venv (do not use UV_SYSTEM_PYTHON: Ubuntu 24.04 is externally managed) +ADD https://astral.sh/uv/install.sh /uv-installer.sh +RUN sh /uv-installer.sh && rm /uv-installer.sh +ENV PATH="/root/.local/bin:$PATH" + +ENV VENV_PATH=/opt/venv +RUN uv venv "$VENV_PATH" +ENV VIRTUAL_ENV="$VENV_PATH" PATH="$VENV_PATH/bin:$PATH" + +# Install deps with uv: base from lockfile (no app/ yet), then GPU from requirements-gpu-*.txt +WORKDIR /app +COPY pyproject.toml uv.lock ./ +COPY requirements-gpu-pypi.txt requirements-gpu-pytorch.txt requirements-gpu-paddle.txt ./ + +ENV UV_PROJECT_ENVIRONMENT=/opt/venv +ENV UV_NO_DEV=1 +ENV UV_LINK_MODE=copy +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-install-project && \ + uv pip install -r requirements-gpu-pypi.txt && \ + uv pip install -r requirements-gpu-pytorch.txt && \ + uv pip install -r requirements-gpu-paddle.txt && \ + rm -rf /tmp/* + +# Download YOLO models (use venv Python) +WORKDIR /scripts +COPY scripts/download_yolo_models.py . +RUN python download_yolo_models.py + +WORKDIR /app +# Copy app last so changes to app code don't invalidate install/yolo layers +COPY app/ . + +# Non-root user; ensure appuser can use venv +RUN useradd -m -u 1001 appuser && \ + chown -R appuser:appuser /app && \ + chown -R appuser:appuser /opt/venv && \ + chown -R appuser:appuser /app/.ultralytics 2>/dev/null +USER appuser + +# Celery from venv (PATH already set) +CMD ["celery", "-A", "config", "worker", "--loglevel=info", "--concurrency=2", "-E"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b896ecd --- /dev/null +++ b/Makefile @@ -0,0 +1,164 @@ +.PHONY: help install install-dev sync lock test test-cov test-fast lint format clean runserver migrate makemigrations shell superuser collectstatic docker-up docker-down docker-logs docker-build docker-ps celery celery-beat celery-flower seaweedfs-auth + +# Default target +help: + @echo "Available commands:" + @echo " make install - Install production dependencies" + @echo " make install-dev - Install development dependencies" + @echo " make sync - Sync dependencies from lock file" + @echo " make lock - Lock dependencies" + @echo " make test - Run all tests" + @echo " make test-cov - Run tests with coverage report" + @echo " make test-fast - Run tests in parallel" + @echo " make lint - Run linters" + @echo " make format - Format code" + @echo " make clean - Clean temporary files" + @echo " make runserver - Run Django development server" + @echo " make migrate - Run database migrations" + @echo " make makemigrations - Create new migrations" + @echo " make shell - Open Django shell" + @echo " make superuser - Create Django superuser" + @echo " make collectstatic - Collect static files" + @echo " make docker-up - Start Docker services" + @echo " make docker-down - Stop Docker services" + @echo " make docker-logs - View Docker logs" + @echo " make docker-build - Build Docker images" + @echo " make docker-ps - Show Docker container status" + @echo " make celery - Run Celery worker" + @echo " make celery-beat - Run Celery beat scheduler" + @echo " make celery-flower - Run Flower (Celery monitoring)" + @echo " make seaweedfs-auth - Create SeaweedFS S3 access keys" + +# Installation +install: + uv sync --no-dev + +install-dev: + uv sync + +sync: + uv sync + +lock: + uv lock + +# Testing +test: + uv run pytest + +test-cov: + uv run pytest --cov=app --cov-report=html --cov-report=term + +test-fast: + uv run pytest -n auto + +# Code quality +lint: + @echo "Running linters..." + @if command -v ruff > /dev/null; then \ + uv run ruff check app/; \ + else \ + echo "ruff not installed, skipping..."; \ + fi + @if command -v mypy > /dev/null; then \ + uv run mypy app/ || true; \ + else \ + echo "mypy not installed, skipping..."; \ + fi + +format: + @echo "Formatting code..." + @if command -v ruff > /dev/null; then \ + uv run ruff format app/; \ + else \ + echo "ruff not installed, skipping..."; \ + fi + @if command -v black > /dev/null; then \ + uv run black app/ || true; \ + else \ + echo "black not installed, skipping..."; \ + fi + +# Cleanup +clean: + find . -type d -name "__pycache__" -exec rm -r {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete + find . -type f -name "*.pyo" -delete + find . -type d -name "*.egg-info" -exec rm -r {} + 2>/dev/null || true + rm -rf .pytest_cache + rm -rf .coverage + rm -rf htmlcov + rm -rf dist + rm -rf build + rm -rf .mypy_cache + rm -rf .ruff_cache + +# Django commands +runserver: + uv run python app/manage.py runserver + +migrate: + uv run python app/manage.py migrate + +makemigrations: + uv run python app/manage.py makemigrations + +shell: + uv run python app/manage.py shell + +superuser: + uv run python app/manage.py createsuperuser + +collectstatic: + uv run python app/manage.py collectstatic --noinput + +# Docker commands +docker-up: + docker compose up -d + +docker-down: + docker compose down + +docker-logs: + docker compose logs -f + +docker-build: + docker compose build + +docker-ps: + docker compose ps + +# Docker VPS specific +docker-up-vps: + docker compose -f docker-compose.vps.yml up -d + +docker-down-vps: + docker compose -f docker-compose.vps.yml down + +docker-logs-vps: + docker compose -f docker-compose.vps.yml logs -f + +# Docker GPU specific +docker-up-gpu: + docker compose -f docker-compose.gpu.yml up -d + +docker-down-gpu: + docker compose -f docker-compose.gpu.yml down + +docker-logs-gpu: + docker compose -f docker-compose.gpu.yml logs -f + +# Celery commands +celery: + uv run celery -A config.celery worker --loglevel=info + +celery-beat: + uv run celery -A config.celery beat --loglevel=info + +celery-flower: + uv run celery -A config.celery flower + +# SeaweedFS commands +seaweedfs-auth: + @echo "Creating SeaweedFS S3 access keys..." + @uv run python scripts/setup_seaweedfs_auth.py --endpoint ${ENDPOINT:-http://localhost:8333} --output-format env diff --git a/QUICK_START.md b/QUICK_START.md new file mode 100644 index 0000000..cd38727 --- /dev/null +++ b/QUICK_START.md @@ -0,0 +1,74 @@ +# Quick Start - Split Deployment with WireGuard + +**⚠️ Prerequisite:** Set up WireGuard VPN first! See [WIREGUARD_SETUP.md](WIREGUARD_SETUP.md) + +## VPS Setup (5 minutes) + +```bash +# 1. Clone and configure +git clone +cd gallery +cp .env.example .env +# Edit .env with your settings + +# 2. Start all services +docker-compose -f docker-compose.vps.yml up -d + +# 3. Check status +docker-compose -f docker-compose.vps.yml ps + +# 4. Get your VPS IP +hostname -I +``` + +## Local GPU Worker Setup (5 minutes) + +```bash +# 1. Clone and configure +git clone +cd gallery +cp .env.example .env + +# 2. Ensure WireGuard is connected (ping 10.0.0.1 should work) + +# 3. Edit .env - Use WireGuard IP (10.0.0.1): +# celery.broker_url=redis://10.0.0.1:6379/0 +# celery.result_backend=redis://10.0.0.1:6379/0 +# database.host=10.0.0.1 + +# 4. Start GPU worker +docker-compose -f docker-compose.gpu.yml up -d + +# 5. Check logs +docker-compose -f docker-compose.gpu.yml logs -f celery-gpu +``` + +## Verify Everything Works + +1. **Check VPS services:** + - Web: http://YOUR_VPS_IP:8000 + - Flower: http://YOUR_VPS_IP:5555 (should show both workers) + +2. **Check GPU worker logs:** + ```bash + docker-compose -f docker-compose.gpu.yml logs celery-gpu + ``` + +3. **Test task routing:** + - Send a GPU task and verify it processes on local GPU worker + - Send a CPU task and verify it processes on VPS CPU worker + +## Common Issues + +**GPU worker can't connect to Redis:** +- **First check WireGuard:** `sudo wg show` and `ping 10.0.0.1` +- Verify Redis is running: `docker-compose -f docker-compose.vps.yml ps redis` +- Test connection: `redis-cli -h 10.0.0.1 -p 6379 ping` + +**GPU worker can't connect to database:** +- Usually not needed if tasks don't access DB directly +- If needed, configure PostgreSQL for remote access (see DEPLOYMENT.md) + +**Tasks not processing:** +- Check queue names match: CPU tasks → 'cpu' queue, GPU tasks → 'gpu' queue +- Verify routing in `settings.py` CELERY_TASK_ROUTES diff --git a/README.md b/README.md index 41e7ab0..a46b277 100644 --- a/README.md +++ b/README.md @@ -1 +1,128 @@ -# gallery \ No newline at end of file +# SmartGallery + +Intelligent Photo Gallery with AI Tagging, built with Django and SeaweedFS. + +## Features + +- 📷 **Photo Gallery Management**: Organize photos into galleries and albums +- 🤖 **AI-Powered Tagging**: Automatic image tagging using YOLO +- 🔍 **OCR Support**: Extract text from images using PaddleOCR +- 🔐 **Secure File Access**: Signed URLs for media files +- 👥 **Sharing**: Share galleries with other users +- 🏷️ **Tagging System**: User-defined and AI-generated tags +- 🌐 **REST API**: Full REST API using Django REST Framework +- 📦 **SeaweedFS Storage**: Scalable distributed file storage + +## Quick Start + +See [QUICK_START.md](QUICK_START.md) for detailed setup instructions. + +### Basic Setup + +1. **Clone and configure:** + ```bash + git clone + cd gallery + cp .env.example .env + # Edit .env with your settings + ``` + +2. **Install dependencies:** + ```bash + make install-dev + ``` + +3. **Start services:** + ```bash + make docker-up + ``` + +4. **Run migrations:** + ```bash + make migrate + ``` + +5. **Create superuser:** + ```bash + make superuser + ``` + +## Documentation + +- **[QUICK_START.md](QUICK_START.md)** - Quick setup guide +- **[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)** - Production deployment guide +- **[docs/SEAWEEDFS_AUTH.md](docs/SEAWEEDFS_AUTH.md)** - SeaweedFS authentication setup +- **[docs/NGINX_SIGNED_URLS.md](docs/NGINX_SIGNED_URLS.md)** - Nginx signed URL configuration +- **[docs/WIREGUARD_SETUP.md](docs/WIREGUARD_SETUP.md)** - WireGuard VPN setup for split deployment +- **[README_TESTING.md](README_TESTING.md)** - Testing guide + +## SeaweedFS Authentication + +To enable SeaweedFS S3 storage with authentication: + +1. **Start SeaweedFS:** + ```bash + make docker-up + ``` + +2. **Create access keys:** + ```bash + make seaweedfs-auth + ``` + +3. **Add credentials to `.env`:** + ```bash + storage.use_s3=True + storage.s3.access_key_id= + storage.s3.secret_access_key= + ``` + +See [docs/SEAWEEDFS_AUTH.md](docs/SEAWEEDFS_AUTH.md) for detailed instructions. + +## Development + +### Running Tests + +```bash +make test # Run all tests +make test-cov # Run with coverage +make test-fast # Run in parallel +``` + +### Code Quality + +```bash +make lint # Run linters +make format # Format code +``` + +### Django Commands + +```bash +make runserver # Start development server +make migrate # Run migrations +make makemigrations # Create migrations +make shell # Django shell +make superuser # Create superuser +``` + +## Project Structure + +``` +gallery/ +├── app/ # Django application +│ ├── config/ # Django settings and configuration +│ ├── gallery/ # Gallery app (models, views, serializers) +│ └── tests/ # Test suite +├── docs/ # Documentation +├── scripts/ # Utility scripts +├── nginx/ # Nginx configuration +├── docker-compose.yml # Local development +├── docker-compose.vps.yml # VPS deployment +├── docker-compose.gpu.yml # GPU worker deployment +└── Makefile # Common commands +``` + +## License + +[Add your license here] diff --git a/README_TESTING.md b/README_TESTING.md new file mode 100644 index 0000000..1ef6984 --- /dev/null +++ b/README_TESTING.md @@ -0,0 +1,215 @@ +# Testing Guide + +This project uses **pytest** with **pytest-django** for testing. + +## Database Configuration + +**Tests use SQLite** (in-memory) instead of PostgreSQL for faster execution. This is automatically configured in `app/config/test_settings.py`. No PostgreSQL connection is required to run tests. + +The test settings file (`config/test_settings.py`) overrides the production database configuration to use SQLite, preventing connection attempts to the PostgreSQL host "db" during tests. + +## Setup + +### Install Dependencies + +```bash +pip install -e ".[dev]" +``` + +Or install pytest dependencies separately: + +```bash +pip install pytest pytest-django pytest-cov pytest-xdist factory-boy faker +``` + +## Running Tests + +### Run All Tests + +```bash +pytest +``` + +### Run Tests with Coverage + +```bash +pytest --cov=app --cov-report=html +``` + +This will generate an HTML coverage report in `htmlcov/index.html`. + +### Run Specific Tests + +```bash +# Run tests in a specific file +pytest app/gallery/tests/test_models.py + +# Run a specific test class +pytest app/gallery/tests/test_models.py::TestGalleryModel + +# Run a specific test function +pytest app/gallery/tests/test_models.py::TestGalleryModel::test_create_gallery +``` + +### Run Tests in Parallel + +```bash +pytest -n auto # Uses pytest-xdist +``` + +### Run Tests by Marker + +```bash +# Run only unit tests +pytest -m unit + +# Run only integration tests +pytest -m integration + +# Skip slow tests +pytest -m "not slow" +``` + +## Test Structure + +``` +app/ +├── tests/ +│ ├── conftest.py # Shared fixtures +│ ├── __init__.py +│ └── gallery/ +│ ├── __init__.py +│ ├── test_models.py # Model tests +│ ├── test_views.py # View tests +│ └── test_utils.py # Utility function tests +└── config/ + └── test_settings.py # Test-specific Django settings (uses SQLite) +``` + +## Available Fixtures + +### User Fixtures + +- `user` - Creates a test user +- `admin_user` - Creates an admin user +- `authenticated_client` - Django test client with logged-in user +- `authenticated_api_client` - DRF API client with authenticated user + +### Django Fixtures + +- `db` - Database access (auto-enabled for all tests) +- `client` - Django test client +- `api_client` - DRF API client +- `request_factory` - Request factory for testing views +- `settings_override` - Override Django settings in tests + +### Celery Fixtures + +- `celery_config` - Configure Celery for testing (synchronous execution) +- `celery_worker_parameters` - Celery worker parameters + +## Writing Tests + +### Example: Model Test + +```python +import pytest +from gallery.models import Gallery + +@pytest.mark.django_db +class TestGalleryModel: + def test_create_gallery(self, user): + gallery = Gallery.objects.create( + owner=user, + name="Test Gallery" + ) + assert gallery.name == "Test Gallery" +``` + +### Example: View Test + +```python +import pytest +from django.urls import reverse + +@pytest.mark.django_db +def test_gallery_list_view(authenticated_client): + url = reverse('gallery:gallery_list') + response = authenticated_client.get(url) + assert response.status_code == 200 +``` + +### Example: API Test + +```python +import pytest +from django.urls import reverse +from rest_framework import status + +@pytest.mark.django_db +def test_create_gallery_api(authenticated_api_client, user): + url = reverse('gallery:gallery-list') + data = { + 'name': 'Test Gallery', + 'gallery_type': 'private' + } + response = authenticated_api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_201_CREATED +``` + +## Test Markers + +Use markers to categorize tests: + +```python +@pytest.mark.slow +def test_slow_operation(): + pass + +@pytest.mark.integration +def test_integration(): + pass + +@pytest.mark.unit +def test_unit(): + pass +``` + +## Configuration + +Test configuration is in: +- `pytest.ini` - Main pytest configuration (points to `config.test_settings`) +- `pyproject.toml` - Project metadata and optional dependencies +- `app/config/test_settings.py` - Test-specific Django settings (SQLite, no external services) +- `app/tests/conftest.py` - Shared fixtures + +## Coverage + +Coverage reports are generated automatically when running tests. View the HTML report: + +```bash +pytest --cov=app --cov-report=html +open htmlcov/index.html # On macOS +# or +start htmlcov/index.html # On Windows +``` + +## Continuous Integration + +For CI/CD, you can run: + +```bash +pytest --cov=app --cov-report=xml --junitxml=junit.xml +``` + +This generates: +- `coverage.xml` - Coverage report for CI +- `junit.xml` - Test results in JUnit format + +## Tips + +1. **Use fixtures** - Don't create test data manually, use fixtures +2. **Mark slow tests** - Use `@pytest.mark.slow` for tests that take time +3. **Use factories** - Consider using `factory-boy` for complex test data +4. **Test isolation** - Each test gets a fresh database transaction +5. **Parallel testing** - Use `pytest-xdist` for faster test runs diff --git a/app/config/__init__.py b/app/config/__init__.py new file mode 100644 index 0000000..15d7c50 --- /dev/null +++ b/app/config/__init__.py @@ -0,0 +1,5 @@ +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/app/config/asgi.py b/app/config/asgi.py new file mode 100644 index 0000000..ed7c431 --- /dev/null +++ b/app/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for config project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/app/config/celery.py b/app/config/celery.py new file mode 100644 index 0000000..4da6ac2 --- /dev/null +++ b/app/config/celery.py @@ -0,0 +1,47 @@ +""" +Celery configuration for the gallery project. + +This project uses two separate Celery workers: +1. CPU worker (celery) - handles CPU-bound tasks, listens to 'cpu' queue +2. GPU worker (celery-gpu) - handles GPU-intensive tasks (YOLO, PaddleOCR), listens to 'gpu' queue + +To route tasks to specific queues: + +Option 1: Using task routing in settings.py + CELERY_TASK_ROUTES = { + 'app.tasks.gpu_task': {'queue': 'gpu'}, + 'app.tasks.cpu_task': {'queue': 'cpu'}, + } + +Option 2: Using queue parameter when calling tasks + from app.tasks import process_image_with_yolo + process_image_with_yolo.apply_async(args=[image_id], queue='gpu') + +Option 3: Using @app.task decorator + @app.task(queue='gpu') + def process_image_with_yolo(image_id): + # GPU task code + pass +""" +import os +from celery import Celery +from django.conf import settings + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +app = Celery('gallery') + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Load task modules from all registered Django apps. +app.autodiscover_tasks() + + +@app.task(bind=True, ignore_result=True) +def debug_task(self): + print(f'Request: {self.request!r}') diff --git a/app/config/config_loader.py b/app/config/config_loader.py new file mode 100644 index 0000000..b1dc177 --- /dev/null +++ b/app/config/config_loader.py @@ -0,0 +1,172 @@ +""" +Configuration loader that reads from YAML file and environment variables. +Environment variables take precedence over YAML values. +""" +import os +import yaml +from pathlib import Path +from typing import Any, Dict, List, Optional + + +class ConfigLoader: + """Load configuration from YAML file with environment variable overrides.""" + + def __init__(self, config_path: Optional[Path] = None): + """ + Initialize config loader. + + Args: + config_path: Path to YAML config file. If None, tries common locations. + """ + self.config_path = (Path(config_path) if isinstance(config_path, str) else config_path) or self._find_config_file() + self.config: Dict[str, Any] = {} + self._load_config() + + def _find_config_file(self) -> Path: + """Find config.yaml in common locations.""" + # Try in order: /app/config.yaml (container), project root, app directory + possible_paths = [ + Path('/app/config.yaml'), # Docker config mount + Path(__file__).resolve().parent.parent.parent / 'config.yaml', # Project root + Path(__file__).resolve().parent / 'config.yaml', # App directory + ] + + for path in possible_paths: + if path.exists(): + return path + + # Return default path even if it doesn't exist (will use defaults) + return possible_paths[1] + + def _load_config(self) -> None: + """Load YAML configuration file.""" + if self.config_path.exists(): + try: + with open(self.config_path, 'r') as f: + self.config = yaml.safe_load(f) or {} + except Exception as e: + print(f"Warning: Could not load config from {self.config_path}: {e}") + self.config = {} + else: + print(f"Warning: Config file not found at {self.config_path}, using defaults") + self.config = {} + + def _get_env_var_candidates(self, key_path: str) -> List[str]: + """ + Get possible environment variable names for a YAML key path. + Returns list in order of preference (most preferred first). + + Examples: + 'django.secret_key' -> ['django.secret_key', 'DJANGO_SECRET_KEY'] + 'database.name' -> ['database.name', 'DATABASE_NAME'] + """ + return [ + key_path, # Dot-separated lowercase (matches YAML exactly) + key_path.upper().replace('.', '_'), # Uppercase underscore (traditional) + ] + + def get(self, key_path: str, default: Any = None, env_var: Optional[str] = None) -> Any: + """ + Get configuration value by dot-separated key path. + + Args: + key_path: Dot-separated path (e.g., 'django.debug') + default: Default value if not found + env_var: Optional environment variable name. If None, auto-derived from key_path. + Checks both 'django.debug' and 'DJANGO_DEBUG' formats. + + Returns: + Configuration value, with env var taking precedence + """ + # Auto-derive env var candidates from key_path if not provided + if env_var is None: + env_var_candidates = self._get_env_var_candidates(key_path) + else: + env_var_candidates = [env_var] + + # Check environment variables in order of preference + for env_var_name in env_var_candidates: + env_value = os.getenv(env_var_name) + if env_value is not None: + # Try to convert to appropriate type + if isinstance(default, bool): + return env_value.lower() in ('true', '1', 'yes', 'on') + elif isinstance(default, int): + try: + return int(env_value) + except ValueError: + return env_value + elif isinstance(default, list): + return [item.strip() for item in env_value.split(',')] + return env_value + + # Navigate through nested dict + keys = key_path.split('.') + value = self.config + + for key in keys: + if isinstance(value, dict) and key in value: + value = value[key] + else: + return default + + return value + + def get_list(self, key_path: str, default: Optional[list] = None, env_var: Optional[str] = None) -> list: + """ + Get configuration value as a list. + + Args: + key_path: Dot-separated path (e.g., 'django.allowed_hosts') + default: Default value if not found + env_var: Optional environment variable name. If None, auto-derived from key_path. + """ + value = self.get(key_path, default or [], env_var) + if isinstance(value, list): + return value + elif isinstance(value, str): + return [item.strip() for item in value.split(',')] + return default or [] + + def get_bool(self, key_path: str, default: bool = False, env_var: Optional[str] = None) -> bool: + """ + Get configuration value as a boolean. + + Args: + key_path: Dot-separated path (e.g., 'django.debug') + default: Default value if not found + env_var: Optional environment variable name. If None, auto-derived from key_path. + """ + value = self.get(key_path, default, env_var) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in ('true', '1', 'yes', 'on') + return bool(value) + + def get_int(self, key_path: str, default: int = 0, env_var: Optional[str] = None) -> int: + """ + Get configuration value as an integer. + + Args: + key_path: Dot-separated path (e.g., 'celery.task_time_limit') + default: Default value if not found + env_var: Optional environment variable name. If None, auto-derived from key_path. + """ + value = self.get(key_path, default, env_var) + try: + return int(value) + except (ValueError, TypeError): + return default + + +# Global config loader instance +_config_loader: Optional[ConfigLoader] = None + + +def get_config_loader(path: Optional[Path|str] = None) -> ConfigLoader: + """Get or create the global config loader instance.""" + global _config_loader + if _config_loader is None: + _config_loader = ConfigLoader(path) + return _config_loader diff --git a/app/config/example_gpu.py b/app/config/example_gpu.py new file mode 100644 index 0000000..7519e54 --- /dev/null +++ b/app/config/example_gpu.py @@ -0,0 +1,15 @@ +# Option 1: Using queue parameter +from app.tasks import process_image_with_yolo +process_image_with_yolo.apply_async(args=[image_id], queue='gpu') + +# Option 2: Using decorator +@app.task(queue='gpu') +def process_image_with_yolo(image_id): + # Your GPU task code here + pass + +# Option 3: Configure in settings.py +CELERY_TASK_ROUTES = { + 'app.tasks.process_image_with_yolo': {'queue': 'gpu'}, + 'app.tasks.process_image_with_paddleocr': {'queue': 'gpu'}, +} \ No newline at end of file diff --git a/app/config/logging_config.py b/app/config/logging_config.py new file mode 100644 index 0000000..4af16f8 --- /dev/null +++ b/app/config/logging_config.py @@ -0,0 +1,27 @@ +""" +Logging configuration helper for structured logging. + +This module provides a convenient way to get loggers configured with JSON formatting. +Use this instead of importing logging directly for better structured logging. +""" + +import logging + + +def get_logger(name: str = None): + """ + Get a logger instance configured with JSON formatting. + + Args: + name: Logger name (typically __name__). If None, uses 'root'. + + Returns: + A logging.Logger instance configured with JSON formatting. + + Example: + from config.logging_config import get_logger + + logger = get_logger(__name__) + logger.info("User logged in", extra={'user_id': 123, 'ip_address': '192.168.1.1'}) + """ + return logging.getLogger(name) diff --git a/app/config/settings.py b/app/config/settings.py new file mode 100644 index 0000000..4ed60a6 --- /dev/null +++ b/app/config/settings.py @@ -0,0 +1,517 @@ +""" +Django settings for config project. + +Generated by 'django-admin startproject' using Django 5.2.1. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +import os +import json +import logging +from datetime import datetime +from pathlib import Path +from dotenv import load_dotenv + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +# BASE_DIR points to /app (where manage.py is located) +BASE_DIR = Path(__file__).resolve().parent.parent +# Project root (one level up from app/) +PROJECT_ROOT = BASE_DIR.parent + +# Load environment variables from .env file +# Try project root first (for local dev), then fall back to BASE_DIR +load_dotenv(BASE_DIR.parent / '.env') # Project root +load_dotenv(BASE_DIR / '.env', override=False) # App directory (fallback) + +# Load YAML configuration +from .config_loader import get_config_loader +config = get_config_loader(os.getenv('CONFIG_PATH', None)) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +# Uses DJANGO_SECRET_KEY env var or config.yaml django.secret_key +SECRET_KEY = config.get('django.secret_key', 'django-insecure-z&x2fgjdjshurb8*p9jtwlov@hy!_h&+obyi1(kl7u^#u5#dr$') + +# SECURITY WARNING: don't run with debug turned on in production! +# Uses DJANGO_DEBUG env var or config.yaml django.debug +DEBUG = config.get_bool('django.debug', False) + +# Uses DJANGO_ALLOWED_HOSTS env var or config.yaml django.allowed_hosts +ALLOWED_HOSTS = config.get_list('django.allowed_hosts', ['localhost', '127.0.0.1', "0.0.0.0"]) + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.sites', # Required for allauth + + # Third-party + 'rest_framework', # Django REST Framework + + # allauth + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + 'allauth.socialaccount.providers.google', + 'allauth.socialaccount.providers.facebook', + 'allauth.socialaccount.providers.github', + 'allauth.socialaccount.providers.auth0', + + # Local apps + 'gallery', +] + +# Add storages if using S3/SeaweedFS +# Uses STORAGE_USE_S3 env var or config.yaml storage.use_s3 +USE_S3 = config.get_bool('storage.use_s3', False) +if USE_S3: + INSTALLED_APPS.append('storages') + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'allauth.account.middleware.AccountMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], # Add app-level templates directory (app/templates) + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + # Uses DATABASE_NAME, DATABASE_USER, etc. env vars or config.yaml + 'NAME': config.get('database.name', 'gallery'), + 'USER': config.get('database.user', 'postgres'), + 'PASSWORD': config.get('database.password', 'postgres'), + 'HOST': config.get('database.host', 'db'), + 'PORT': config.get('database.port', '5432'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = config.get('django.language_code', 'en-us') +TIME_ZONE = config.get('django.timezone', 'UTC') + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +def _resolve_root_path(value: str, base_dir: Path) -> Path: + """ + Resolve a filesystem path from config/env. + + - If `value` is absolute, use it as-is. + - If `value` is relative, resolve it under `base_dir`. + """ + p = Path(value) + return p if p.is_absolute() else (base_dir / p) + +def _normalize_url_prefix(value: str, default: str) -> str: + """ + Normalize URL prefixes like STATIC_URL and MEDIA_URL. + + Ensures: + - starts with '/' + - ends with '/' + """ + v = (value or "").strip() + if not v: + v = default + if not v.startswith("/"): + v = "/" + v + if not v.endswith("/"): + v = v + "/" + return v + + +# Allow env vars to override config.yaml (useful in Docker/K8s) +STATIC_URL = _normalize_url_prefix( + os.getenv('STATIC_URL', config.get('static_files.static_url', '/static/')), + default='/static/', +) +STATIC_ROOT_VALUE = os.getenv('STATIC_ROOT', config.get('static_files.static_root', 'staticfiles')) +STATIC_ROOT = _resolve_root_path(STATIC_ROOT_VALUE, BASE_DIR) + +MEDIA_URL = _normalize_url_prefix( + os.getenv('MEDIA_URL', config.get('static_files.media_url', '/media/')), + default='/media/', +) +MEDIA_ROOT_VALUE = os.getenv('MEDIA_ROOT', config.get('static_files.media_root', 'media')) +MEDIA_ROOT = _resolve_root_path(MEDIA_ROOT_VALUE, BASE_DIR) + +# SeaweedFS / S3 Storage Configuration +if USE_S3: + # SeaweedFS S3-compatible storage + # Uses STORAGE_S3_ACCESS_KEY_ID, STORAGE_S3_SECRET_ACCESS_KEY, etc. env vars or config.yaml + AWS_ACCESS_KEY_ID = config.get('storage.s3.access_key_id', '') + AWS_SECRET_ACCESS_KEY = config.get('storage.s3.secret_access_key', '') + AWS_STORAGE_BUCKET_NAME = config.get('storage.s3.bucket_name', 'gallery') + AWS_S3_ENDPOINT_URL = config.get('storage.s3.endpoint_url', 'http://seaweedfs:8333') + AWS_S3_REGION_NAME = config.get('storage.s3.region_name', 'us-east-1') + AWS_S3_USE_SSL = config.get_bool('storage.s3.use_ssl', False) + AWS_S3_FILE_OVERWRITE = False + AWS_DEFAULT_ACL = None + + # Use S3 for media files + DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' + STATICFILES_STORAGE = 'storages.backends.s3boto3.S3StaticStorage' + STORAGES = { + 'default': { + 'BACKEND': 'storages.backends.s3boto3.S3Boto3Storage', + 'OPTIONS': { + 'bucket_name': AWS_STORAGE_BUCKET_NAME, + 'region_name': AWS_S3_REGION_NAME, + }, + }, + # 'staticfiles': { + # 'BACKEND': 'storages.backends.s3boto3.S3StaticStorage', + # 'OPTIONS': { + # 'bucket_name': AWS_STORAGE_BUCKET_NAME, + # 'region_name': AWS_S3_REGION_NAME, + # }, + # }, + 'staticfiles': {'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage'}, + } + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Authentication Configuration +AUTHENTICATION_BACKENDS = [ + # Django default authentication (username/password) + 'django.contrib.auth.backends.ModelBackend', + # allauth authentication (social accounts) + 'allauth.account.auth_backends.AuthenticationBackend', +] + +# Site ID (required for allauth) +# Uses DJANGO_SITE_ID env var or config.yaml django.site_id +SITE_ID = config.get_int('django.site_id', 1) + +# allauth Configuration +# ACCOUNT_AUTHENTICATION_METHOD = 'email' # Use email instead of username # Also deprecated +ACCOUNT_LOGIN_METHODS = {'email'} +# ACCOUNT_EMAIL_REQUIRED = True # deprecated +ACCOUNT_SIGNUP_FIELDS = ['email*', 'email2*', 'password1*', 'password2*'] +# ACCOUNT_USERNAME_REQUIRED = False +# Uses AUTHENTICATION_ACCOUNT_EMAIL_VERIFICATION env var or config.yaml +ACCOUNT_EMAIL_VERIFICATION = config.get('authentication.account_email_verification', 'mandatory') +ACCOUNT_UNIQUE_EMAIL = True +# ACCOUNT_SIGNUP_EMAIL_ENTER_TWICE = True # Deprecated +ACCOUNT_SIGNUP_FIELDS = ['email*', 'email2*', 'password1*', 'password2*'] +ACCOUNT_SESSION_REMEMBER = True + +# Login/Logout URLs +LOGIN_URL = '/accounts/login/' +LOGIN_REDIRECT_URL = config.get('authentication.login_redirect_url', '/') +LOGOUT_REDIRECT_URL = config.get('authentication.logout_redirect_url', '/') + +# Social Account Providers Configuration +SOCIALACCOUNT_PROVIDERS = { + 'auth0': { + 'AUTH0_URL': config.get('social_auth.auth0.auth0_url', ''), + 'OAUTH_PKCE_ENABLED': True, + 'APP': { + 'client_id': config.get('social_auth.auth0.client_id', ''), + 'secret': config.get('social_auth.auth0.secret', ''), + 'key': '', + } + } +} + +# Google OAuth +# Uses SOCIAL_AUTH_GOOGLE_ENABLED, SOCIAL_AUTH_GOOGLE_CLIENT_ID, etc. env vars or config.yaml +if config.get_bool('social_auth.google.enabled', False): + SOCIALACCOUNT_PROVIDERS['google'] = { + 'SCOPE': ['profile', 'email'], + 'AUTH_PARAMS': {'access_type': 'online'}, + 'APP': { + 'client_id': config.get('social_auth.google.client_id', ''), + 'secret': config.get('social_auth.google.secret', ''), + 'key': '', + } + } + +# Facebook OAuth +# Uses SOCIAL_AUTH_FACEBOOK_ENABLED, SOCIAL_AUTH_FACEBOOK_APP_ID, etc. env vars or config.yaml +if config.get_bool('social_auth.facebook.enabled', False): + SOCIALACCOUNT_PROVIDERS['facebook'] = { + 'METHOD': 'oauth2', + 'SCOPE': ['email', 'public_profile'], + 'AUTH_PARAMS': {'auth_type': 'reauthenticate'}, + 'INIT_PARAMS': {'cookie': True}, + 'FIELDS': [ + 'id', 'first_name', 'last_name', 'middle_name', + 'name', 'name_format', 'picture', 'short_name', 'email', + ], + 'EXCHANGE_TOKEN': True, + 'APP': { + 'client_id': config.get('social_auth.facebook.app_id', ''), + 'secret': config.get('social_auth.facebook.app_secret', ''), + 'key': '', + } + } + +# GitHub OAuth +# Uses SOCIAL_AUTH_GITHUB_ENABLED, SOCIAL_AUTH_GITHUB_CLIENT_ID, etc. env vars or config.yaml +if config.get_bool('social_auth.github.enabled', False): + SOCIALACCOUNT_PROVIDERS['github'] = { + 'SCOPE': ['user', 'user:email'], + 'APP': { + 'client_id': config.get('social_auth.github.client_id', ''), + 'secret': config.get('social_auth.github.secret', ''), + 'key': '', + } + } + +# Celery Configuration +# Uses CELERY_BROKER_URL, CELERY_RESULT_BACKEND, etc. env vars or config.yaml +CELERY_BROKER_URL = config.get('celery.broker_url', 'redis://redis:6379/0') +CELERY_RESULT_BACKEND = config.get('celery.result_backend', 'redis://redis:6379/0') +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = TIME_ZONE +CELERY_TASK_TRACK_STARTED = True +CELERY_TASK_TIME_LIMIT = config.get_int('celery.task_time_limit', 1800) # seconds +CELERY_TASK_SOFT_TIME_LIMIT = config.get_int('celery.task_soft_time_limit', 1500) # seconds +CELERY_WORKER_PREFETCH_MULTIPLIER = config.get_int('celery.worker_prefetch_multiplier', 1) +CELERY_WORKER_MAX_TASKS_PER_CHILD = config.get_int('celery.worker_max_tasks_per_child', 1000) + +# Celery Queue Configuration +# Separate queues for CPU and GPU tasks +CELERY_TASK_ROUTES = { + # Route GPU-intensive tasks (YOLO, PaddleOCR) to GPU queue + 'gallery.tasks.process_picture_ai': {'queue': 'gpu'}, + # Route CPU tasks to CPU queue + # Example: 'gallery.tasks.process_metadata': {'queue': 'cpu'}, +} +CELERY_TASK_DEFAULT_QUEUE = 'cpu' # Default queue for tasks without explicit routing +CELERY_TASK_DEFAULT_EXCHANGE = 'tasks' +CELERY_TASK_DEFAULT_ROUTING_KEY = 'cpu' + +# Gallery App Configuration +GALLERY_MEDIA_BASE_URL = config.get('gallery.media_base_url', '/media') +GALLERY_SIGNED_URL_SECRET = config.get('gallery.signed_url_secret', None) # Optional, falls back to SECRET_KEY +GALLERY_SIGNED_URL_EXPIRES_IN = config.get_int('gallery.signed_url_expires_in', 3600) # 1 hour default + +# REST Framework Configuration +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.SessionAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 50, +} + +# Structured Logging Configuration +class JSONFormatter(logging.Formatter): + """Custom JSON formatter for structured logging.""" + + def format(self, record): + """Format log record as JSON.""" + log_data = { + 'timestamp': datetime.utcnow().isoformat() + 'Z', + 'level': record.levelname, + 'logger': record.name, + 'message': record.getMessage(), + 'module': record.module, + 'function': record.funcName, + 'line': record.lineno, + } + + # Add exception info if present + if record.exc_info: + log_data['exception'] = self.formatException(record.exc_info) + + # Add extra fields from record + if hasattr(record, 'user_id'): + log_data['user_id'] = record.user_id + if hasattr(record, 'request_id'): + log_data['request_id'] = record.request_id + if hasattr(record, 'ip_address'): + log_data['ip_address'] = record.ip_address + if hasattr(record, 'path'): + log_data['path'] = record.path + if hasattr(record, 'method'): + log_data['method'] = record.method + if hasattr(record, 'status_code'): + log_data['status_code'] = record.status_code + if hasattr(record, 'duration'): + log_data['duration'] = record.duration + + # Add any other extra fields + for key, value in record.__dict__.items(): + if key not in [ + 'name', 'msg', 'args', 'created', 'filename', 'funcName', + 'levelname', 'levelno', 'lineno', 'module', 'msecs', + 'message', 'pathname', 'process', 'processName', 'relativeCreated', + 'thread', 'threadName', 'exc_info', 'exc_text', 'stack_info', + 'asctime', 'taskName', 'task_id' + ]: + log_data[key] = value + + # Never let logging crash the app: coerce unknown objects (e.g. request/socket) to string. + return json.dumps(log_data, ensure_ascii=False, default=str) + +# Get log level from config or environment, default to INFO +LOG_LEVEL = config.get('logging.level', os.getenv('LOG_LEVEL', 'INFO')).upper() +LOG_FILE = config.get('logging.file', os.getenv('LOG_FILE', None)) +LOG_FILE_MAX_BYTES = config.get_int('logging.file_max_bytes', 10 * 1024 * 1024) # 10MB +LOG_FILE_BACKUP_COUNT = config.get_int('logging.file_backup_count', 5) + +# Configure logging handlers +handlers = { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'json', + 'level': LOG_LEVEL, + }, +} + +# Add file handler if log file is configured +if LOG_FILE: + handlers['file'] = { + 'class': 'logging.handlers.RotatingFileHandler', + 'filename': LOG_FILE, + 'maxBytes': LOG_FILE_MAX_BYTES, + 'backupCount': LOG_FILE_BACKUP_COUNT, + 'formatter': 'json', + 'level': LOG_LEVEL, + } + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'json': { + '()': JSONFormatter, + }, + 'verbose': { + 'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}', + 'style': '{', + }, + 'simple': { + 'format': '{levelname} {message}', + 'style': '{', + }, + }, + 'handlers': handlers, + 'root': { + 'handlers': list(handlers.keys()), + 'level': LOG_LEVEL, + }, + 'loggers': { + 'django': { + 'handlers': list(handlers.keys()), + 'level': config.get('logging.django_level', os.getenv('DJANGO_LOG_LEVEL', 'INFO')).upper(), + 'propagate': False, + }, + 'django.request': { + 'handlers': list(handlers.keys()), + 'level': config.get('logging.django_request_level', os.getenv('DJANGO_REQUEST_LOG_LEVEL', 'WARNING')).upper(), + 'propagate': False, + }, + 'django.server': { + 'handlers': list(handlers.keys()), + 'level': config.get('logging.django_server_level', os.getenv('DJANGO_SERVER_LOG_LEVEL', 'WARNING')).upper(), + 'propagate': False, + }, + 'django.db.backends': { + 'handlers': list(handlers.keys()), + 'level': config.get('logging.django_db_level', os.getenv('DJANGO_DB_LOG_LEVEL', 'WARNING')).upper(), + 'propagate': False, + }, + 'gallery': { + 'handlers': list(handlers.keys()), + 'level': LOG_LEVEL, + 'propagate': False, + }, + 'celery': { + 'handlers': list(handlers.keys()), + 'level': config.get('logging.celery_level', os.getenv('CELERY_LOG_LEVEL', 'INFO')).upper(), + 'propagate': False, + }, + 'boto3': { + 'handlers': list(handlers.keys()), + 'level': config.get('logging.boto3_level', os.getenv('BOTO3_LOG_LEVEL', 'WARNING')).upper(), + 'propagate': False, + }, + 'botocore': { + 'handlers': list(handlers.keys()), + 'level': config.get('logging.botocore_level', os.getenv('BOTOCORE_LOG_LEVEL', 'WARNING')).upper(), + 'propagate': False, + }, + }, +} diff --git a/app/config/test_settings.py b/app/config/test_settings.py new file mode 100644 index 0000000..8accc13 --- /dev/null +++ b/app/config/test_settings.py @@ -0,0 +1,64 @@ +""" +Django test settings for gallery project. + +This settings file is used specifically for running tests. +It uses SQLite instead of PostgreSQL and disables unnecessary features. +""" +# Import all settings first +from .settings import * # noqa: F401, F403 + +# Override database to use SQLite for tests +# This MUST be after the import to override the PostgreSQL config from settings.py +# This prevents trying to connect to PostgreSQL host "db" during test setup +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', # In-memory SQLite for fastest tests + } +} + +# Disable migrations during tests (faster) +class DisableMigrations: + def __contains__(self, item): + return True + + def __getitem__(self, item): + return None + + +MIGRATION_MODULES = DisableMigrations() + +# Speed up password hashing for tests +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.MD5PasswordHasher', +] + +# Disable caching for tests +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', + } +} + +# Disable email sending during tests +EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' + +# Use database session backend for tests (more reliable than cache with DummyCache) +SESSION_ENGINE = 'django.contrib.sessions.backends.db' + +# Disable logging during tests (optional, for cleaner output) +LOGGING_CONFIG = None + +# Celery configuration for tests (synchronous execution) +CELERY_TASK_ALWAYS_EAGER = True +CELERY_TASK_EAGER_PROPAGATES = True +CELERY_BROKER_URL = 'memory://' +CELERY_RESULT_BACKEND = 'cache+memory://' + +# Disable external services +USE_S3 = False + +# Test-specific settings +DEBUG = False +SECRET_KEY = 'test-secret-key-for-testing-only' # Not used in production +ACCOUNT_EMAIL_VERIFICATION = "none" \ No newline at end of file diff --git a/app/config/urls.py b/app/config/urls.py new file mode 100644 index 0000000..1b6e6e6 --- /dev/null +++ b/app/config/urls.py @@ -0,0 +1,24 @@ +""" +URL configuration for config project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include + +urlpatterns = [ + path('admin/', admin.site.urls), + path('accounts/', include('allauth.urls')), + path('', include('gallery.urls')), +] diff --git a/app/config/wsgi.py b/app/config/wsgi.py new file mode 100644 index 0000000..e2fbd58 --- /dev/null +++ b/app/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/app/gallery/README.md b/app/gallery/README.md new file mode 100644 index 0000000..50e94dd --- /dev/null +++ b/app/gallery/README.md @@ -0,0 +1,143 @@ +# Gallery Django App + +Django app for managing photo galleries with support for SeaweedFS storage and signed URLs. + +## Models + +### Gallery +- Top-level container for albums +- Tied to User (owner) +- Has type: `private` (default) or `public` +- Fields: name, description, tags, is_favorite +- Supports sharing with other users via `GalleryShare` + +### Album +- Container for pictures within a gallery +- Belongs to a Gallery +- Fields: name, description, tags, exif_metadata +- Soft delete support + +### Picture +- Individual photo stored on SeaweedFS +- Belongs to an Album +- Stores SeaweedFS file ID and URL +- AI-generated tags (YOLO) and OCR text (PaddleOCR) +- EXIF metadata +- User-defined tags +- Soft delete support + +## Features + +- **Signed URLs**: Generate secure, time-limited URLs for media files +- **Soft Delete**: All models support soft deletion with `deleted_at` field +- **Sharing**: Galleries can be shared with other users +- **AI Integration**: Support for AI tags and OCR text +- **REST API**: Full REST API using Django REST Framework + +## API Endpoints + +### Galleries +- `GET /api/galleries/` - List galleries +- `POST /api/galleries/` - Create gallery +- `GET /api/galleries/{id}/` - Get gallery details +- `PUT /api/galleries/{id}/` - Update gallery +- `DELETE /api/galleries/{id}/` - Delete gallery +- `POST /api/galleries/{id}/share/` - Share gallery with users +- `POST /api/galleries/{id}/unshare/` - Remove share access +- `POST /api/galleries/{id}/toggle_favorite/` - Toggle favorite + +### Albums +- `GET /api/albums/` - List albums +- `POST /api/albums/` - Create album +- `GET /api/albums/{id}/` - Get album with pictures +- `PUT /api/albums/{id}/` - Update album +- `DELETE /api/albums/{id}/` - Delete album + +### Pictures +- `GET /api/pictures/` - List pictures +- `POST /api/pictures/` - Create picture +- `GET /api/pictures/{id}/` - Get picture details +- `PUT /api/pictures/{id}/` - Update picture +- `DELETE /api/pictures/{id}/` - Delete picture +- `GET /api/pictures/{id}/signed_url/` - Get new signed URL +- `POST /api/pictures/{id}/toggle_favorite/` - Toggle favorite +- `POST /api/pictures/{id}/add_tag/` - Add tag +- `POST /api/pictures/{id}/remove_tag/` - Remove tag + +## Signed URLs + +Signed URLs are generated using MD5 (compatible with Nginx `secure_link` module): + +```python +from gallery.utils import generate_signed_url + +signed = generate_signed_url('file-id-123', expires_in=3600) +# Returns: { +# 'url': '/media/file-id-123?st=signature&e=1234567890', +# 'expires_at': 1234567890, +# 'expires_in': 3600 +# } +``` + +URLs are validated by Nginx before serving files. See `docs/NGINX_SIGNED_URLS.md` for configuration. + +## Settings + +Add to `settings.py`: + +```python +# Gallery App Configuration +GALLERY_MEDIA_BASE_URL = '/media' +GALLERY_SIGNED_URL_SECRET = 'your-secret-key' # Optional, defaults to SECRET_KEY +GALLERY_SIGNED_URL_EXPIRES_IN = 3600 # 1 hour default +``` + +## Usage Example + +```python +from gallery.models import Gallery, Album, Picture +from gallery.utils import generate_signed_url + +# Create gallery +gallery = Gallery.objects.create( + owner=user, + name="My Vacation", + gallery_type=Gallery.GalleryType.PRIVATE +) + +# Create album +album = Album.objects.create( + gallery=gallery, + name="Beach Photos" +) + +# Create picture +picture = Picture.objects.create( + album=album, + title="Sunset", + seaweedfs_file_id="abc123", + seaweedfs_url="http://seaweedfs:8888/abc123" +) + +# Generate signed URL +signed_url = generate_signed_url(picture.seaweedfs_file_id) +print(signed_url['url']) # /media/abc123?st=...&e=... +``` + +## Permissions + +- Users can only access galleries they own or that are shared with them +- Gallery owners can share/unshare galleries +- Shared users have read-only access by default (can be set to edit) + +## Soft Delete + +All models support soft deletion: + +```python +gallery.soft_delete() # Sets deleted_at +gallery.restore() # Clears deleted_at +gallery.is_deleted # Check if deleted +``` + +Soft-deleted items are automatically excluded from querysets. diff --git a/app/gallery/README_FRONTEND.md b/app/gallery/README_FRONTEND.md new file mode 100644 index 0000000..ef896a6 --- /dev/null +++ b/app/gallery/README_FRONTEND.md @@ -0,0 +1,118 @@ +# Gallery Frontend - Quick Start + +## Overview + +The frontend is a Multi-Page Application (MPA) using: +- **Django Templates** for server-side rendering +- **HTMX** for interactive updates +- **Tailwind CSS** for styling + +## Features Implemented + +### ✅ Interactive Features (HTMX) + +1. **Toggle Favorite** - Click ⭐/☆ to toggle without page refresh +2. **Add Tags** - Inline form to add tags instantly +3. **Remove Tags** - Click × on any tag to remove it + +### ✅ Pages + +1. **Gallery List** (`/`) - Browse all galleries with search/filter +2. **Gallery Detail** (`/galleries//`) - View gallery with albums +3. **Gallery Create/Edit** - Forms for creating/editing galleries +4. **Album Detail** (`/albums//`) - View album with picture grid +5. **Picture Detail** (`/pictures//`) - View picture with metadata + +## How HTMX Works + +### Example: Toggle Favorite + +```html + + +``` + +**What happens:** +1. User clicks button +2. HTMX sends POST request to `/galleries//toggle-favorite/` +3. Server returns updated button HTML +4. HTMX replaces the button with new HTML +5. No page refresh! + +### Example: Remove Tag + +```html + +``` + +**What happens:** +1. User clicks × on tag +2. HTMX sends DELETE request with tag name +3. Server removes tag and returns empty string +4. HTMX removes the `` element +5. Tag disappears instantly! + +## URL Routes + +| URL | View | Description | +|-----|------|-------------| +| `/` | `gallery_list` | List all galleries | +| `/galleries/create/` | `gallery_create` | Create new gallery | +| `/galleries//` | `gallery_detail` | View gallery | +| `/galleries//edit/` | `gallery_edit` | Edit gallery | +| `/albums//` | `album_detail` | View album | +| `/pictures//` | `picture_detail` | View picture | + +## HTMX Endpoints + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/galleries//toggle-favorite/` | POST | Toggle favorite | +| `/galleries//add-tag/` | POST | Add tag | +| `/galleries//remove-tag/` | DELETE | Remove tag | +| `/albums//add-tag/` | POST | Add tag | +| `/albums//remove-tag/` | DELETE | Remove tag | +| `/pictures//toggle-favorite/` | POST | Toggle favorite | + +## Testing + +1. **Start the server:** + ```bash + python manage.py runserver + ``` + +2. **Visit:** `http://localhost:8000/` + +3. **Test HTMX:** + - Click ⭐ to toggle favorite (should update instantly) + - Add a tag using the inline form + - Click × on a tag to remove it + +## Customization + +### Styling +- Edit `static/gallery/css/style.css` for custom styles +- Modify Tailwind classes in templates +- Or replace Tailwind with your CSS framework + +### HTMX Behavior +- Modify `hx-target`, `hx-swap` attributes +- Add `hx-trigger` for custom triggers +- Use `hx-indicator` for loading states + +## Next Steps + +- [ ] Add image upload functionality +- [ ] Implement picture grid with lazy loading +- [ ] Add search with live results (HTMX) +- [ ] Create bulk actions (HTMX) +- [ ] Add drag-and-drop for organizing diff --git a/app/gallery/__init__.py b/app/gallery/__init__.py new file mode 100644 index 0000000..b7daa4f --- /dev/null +++ b/app/gallery/__init__.py @@ -0,0 +1 @@ +default_app_config = 'gallery.apps.GalleryConfig' diff --git a/app/gallery/admin.py b/app/gallery/admin.py new file mode 100644 index 0000000..6fb8c9c --- /dev/null +++ b/app/gallery/admin.py @@ -0,0 +1,51 @@ +from django.contrib import admin +from .models import Gallery, Album, Picture, PictureTag, GalleryShare, Tag + + +@admin.register(Tag) +class TagAdmin(admin.ModelAdmin): + list_display = ['name', 'slug', 'usage_count', 'created_at'] + list_filter = ['created_at'] + search_fields = ['name', 'slug'] + readonly_fields = ['slug', 'usage_count', 'created_at'] + ordering = ['name'] + + +@admin.register(Gallery) +class GalleryAdmin(admin.ModelAdmin): + list_display = ['name', 'owner', 'gallery_type', 'is_favorite', 'created_at', 'deleted_at'] + list_filter = ['gallery_type', 'is_favorite', 'tags', 'created_at', 'deleted_at'] + search_fields = ['name', 'description', 'owner__username', 'owner__email', 'tags__name'] + readonly_fields = ['created_at', 'updated_at'] + # filter_horizontal = ['shared_with', 'tags'] + + +@admin.register(Album) +class AlbumAdmin(admin.ModelAdmin): + list_display = ['name', 'gallery', 'created_at', 'deleted_at'] + list_filter = ['tags', 'created_at', 'deleted_at'] + search_fields = ['name', 'description', 'gallery__name', 'tags__name'] + readonly_fields = ['created_at', 'updated_at'] + filter_horizontal = ['tags'] + + +class PictureTagInline(admin.TabularInline): + model = PictureTag + extra = 0 + autocomplete_fields = ['tag'] + + +@admin.register(Picture) +class PictureAdmin(admin.ModelAdmin): + list_display = ['title', 'album', 'file_size', 'width', 'height', 'is_favorite', 'uploaded_at', 'deleted_at'] + list_filter = ['is_favorite', 'uploaded_at', 'deleted_at', 'mime_type'] + search_fields = ['title', 'description', 'album__name', 'ocr_text', 'tags__name'] + readonly_fields = ['uploaded_at', 'updated_at', 'seaweedfs_file_id'] + inlines = [PictureTagInline] + + +@admin.register(GalleryShare) +class GalleryShareAdmin(admin.ModelAdmin): + list_display = ['gallery', 'user', 'can_edit', 'shared_at'] + list_filter = ['can_edit', 'shared_at'] + search_fields = ['gallery__name', 'user__username', 'user__email'] diff --git a/app/gallery/apps.py b/app/gallery/apps.py new file mode 100644 index 0000000..9db82fd --- /dev/null +++ b/app/gallery/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class GalleryConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'gallery' + verbose_name = 'Gallery' diff --git a/app/gallery/forms.py b/app/gallery/forms.py new file mode 100644 index 0000000..e6dc986 --- /dev/null +++ b/app/gallery/forms.py @@ -0,0 +1,164 @@ +""" +Django forms for Gallery app +""" +from django import forms +from .models import Gallery, Album, Picture + + +class GalleryForm(forms.ModelForm): + """Form for creating/editing Gallery""" + tags = forms.CharField( + required=False, + help_text="Enter tags separated by commas", + widget=forms.TextInput(attrs={'placeholder': 'vacation, beach, summer'}) + ) + + class Meta: + model = Gallery + fields = ['name', 'description', 'gallery_type'] + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control'}), + 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}), + 'gallery_type': forms.Select(attrs={'class': 'form-control'}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance and self.instance.pk: + # Pre-populate tags for editing + self.fields['tags'].initial = ', '.join([tag.name for tag in self.instance.tags.all()]) + + def save(self, commit=True): + gallery = super().save(commit=commit) + # print(f"Gallery: {gallery}") + + # Handle tags + # tags_str = self.cleaned_data.get('tags', '') + # if tags_str: + # tag_names = [tag.strip() for tag in tags_str.split(',') if tag.strip()] + # gallery.set_tags(tag_names) + # elif commit: + # # Clear tags if empty + # gallery.tags.clear() + + return gallery + + +class AlbumForm(forms.ModelForm): + """Form for creating/editing Album""" + tags = forms.CharField( + required=False, + help_text="Enter tags separated by commas", + widget=forms.TextInput(attrs={'placeholder': 'summer, beach, 2024'}) + ) + + class Meta: + model = Album + fields = ['name', 'description'] + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control'}), + 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}), + } + + def __init__(self, *args, **kwargs): + self.gallery = kwargs.pop('gallery', None) + super().__init__(*args, **kwargs) + if self.instance and self.instance.pk: + # Pre-populate tags for editing + self.fields['tags'].initial = ', '.join([tag.name for tag in self.instance.tags.all()]) + + def save(self, commit=True): + album = super().save(commit=False) + if self.gallery: + album.gallery = self.gallery + if commit: + album.save() + + # Handle tags + tags_str = self.cleaned_data.get('tags', '') + if tags_str: + tag_names = [tag.strip() for tag in tags_str.split(',') if tag.strip()] + album.set_tags(tag_names) + elif commit: + # Clear tags if empty + album.tags.clear() + + return album + + +class PictureEditForm(forms.ModelForm): + """Form for editing picture title, description, and user tags.""" + tags = forms.CharField( + required=False, + help_text="Enter tags separated by commas", + widget=forms.TextInput(attrs={'placeholder': 'sunset, beach, vacation'}), + ) + + class Meta: + model = Picture + fields = ['title', 'description'] + widgets = { + 'title': forms.TextInput(attrs={'class': 'form-control'}), + 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance and self.instance.pk: + self.fields['tags'].initial = ', '.join([tag.name for tag in self.instance.user_tags]) + + def save(self, commit=True): + picture = super().save(commit=commit) + if commit: + tags_str = self.cleaned_data.get('tags', '') + tag_names = [t.strip() for t in tags_str.split(',') if t.strip()] + picture.set_tags(tag_names) + return picture + + +class PictureUploadForm(forms.ModelForm): + """Form for uploading one or more pictures, or an archive (ZIP/TAR).""" + file = forms.ImageField( + required=False, + help_text="Single image (optional if using multiple files or archive)", + ) + tags = forms.CharField( + required=False, + help_text="Enter tags separated by commas (applied to all uploads)", + widget=forms.TextInput(attrs={'placeholder': 'sunset, beach, vacation'}), + ) + archive = forms.FileField( + required=False, + help_text="ZIP or TAR archive containing images (up to 100MB)", + widget=forms.FileInput(attrs={'accept': '.zip,.tar,.tar.gz,.tgz'}), + ) + extract_with_ai = forms.BooleanField( + required=False, + initial=True, + label="Extract text and objects (YOLO + PaddleOCR)", + help_text="Run object detection (YOLO → ai_tags) and OCR (PaddleOCR → ocr_text) on uploaded images.", + ) + extract_with_exif = forms.BooleanField( + required=False, + initial=False, + label="Extract EXIF metadata (camera, location, etc.)", + help_text="Extract camera make/model, date taken, GPS and add as tags (runs on CPU worker).", + ) + + class Meta: + model = Picture + fields = ['title', 'description'] + widgets = { + 'title': forms.TextInput(attrs={'class': 'form-control'}), + 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + } + + def __init__(self, *args, **kwargs): + self.album = kwargs.pop('album', None) + super().__init__(*args, **kwargs) + + def save(self, commit=True): + picture = super().save(commit=False) + if self.album: + picture.album = self.album + return picture diff --git a/app/gallery/migrations/0001_initial.py b/app/gallery/migrations/0001_initial.py new file mode 100644 index 0000000..996a68f --- /dev/null +++ b/app/gallery/migrations/0001_initial.py @@ -0,0 +1,140 @@ +# Generated by Django 6.0.1 on 2026-01-26 21:41 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Gallery', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, validators=[django.core.validators.MinLengthValidator(1)], verbose_name='Gallery Name')), + ('description', models.TextField(blank=True, verbose_name='Description')), + ('gallery_type', models.CharField(choices=[('private', 'Private'), ('public', 'Public')], default='private', max_length=10, verbose_name='Gallery Type')), + ('is_favorite', models.BooleanField(default=False, verbose_name='Is Favorite')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='galleries', to=settings.AUTH_USER_MODEL, verbose_name='Owner')), + ], + options={ + 'verbose_name': 'Gallery', + 'verbose_name_plural': 'Galleries', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='GalleryShare', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('shared_at', models.DateTimeField(auto_now_add=True, verbose_name='Shared At')), + ('can_edit', models.BooleanField(default=False, verbose_name='Can Edit')), + ('gallery', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='gallery.gallery')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gallery_shares', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Gallery Share', + 'verbose_name_plural': 'Gallery Shares', + 'ordering': ['-shared_at'], + 'unique_together': {('gallery', 'user')}, + }, + ), + migrations.AddField( + model_name='gallery', + name='shared_with', + field=models.ManyToManyField(blank=True, related_name='shared_galleries', through='gallery.GalleryShare', to=settings.AUTH_USER_MODEL, verbose_name='Shared With'), + ), + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(db_index=True, help_text='Tag name (case-insensitive, normalized)', max_length=100, unique=True, verbose_name='Tag Name')), + ('slug', models.SlugField(help_text='URL-friendly tag identifier', max_length=100, unique=True, verbose_name='Tag Slug')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('usage_count', models.PositiveIntegerField(default=0, help_text='Number of times this tag is used across all objects', verbose_name='Usage Count')), + ], + options={ + 'verbose_name': 'Tag', + 'verbose_name_plural': 'Tags', + 'ordering': ['name'], + 'indexes': [models.Index(fields=['name'], name='gallery_tag_name_2a271b_idx'), models.Index(fields=['slug'], name='gallery_tag_slug_90ef48_idx')], + }, + ), + migrations.AddField( + model_name='gallery', + name='tags', + field=models.ManyToManyField(blank=True, help_text='Tags for the gallery', related_name='galleries', to='gallery.tag', verbose_name='Tags'), + ), + migrations.CreateModel( + name='Album', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, validators=[django.core.validators.MinLengthValidator(1)], verbose_name='Album Name')), + ('description', models.TextField(blank=True, verbose_name='Description')), + ('exif_metadata', models.JSONField(blank=True, default=dict, help_text='EXIF metadata extracted from pictures')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')), + ('gallery', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='albums', to='gallery.gallery', verbose_name='Gallery')), + ('tags', models.ManyToManyField(blank=True, help_text='Tags for the album', related_name='albums', to='gallery.tag', verbose_name='Tags')), + ], + options={ + 'verbose_name': 'Album', + 'verbose_name_plural': 'Albums', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Picture', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(blank=True, max_length=200, verbose_name='Title')), + ('description', models.TextField(blank=True, verbose_name='Description')), + ('seaweedfs_file_id', models.CharField(help_text='File ID from SeaweedFS', max_length=100, unique=True, verbose_name='SeaweedFS File ID')), + ('seaweedfs_url', models.URLField(blank=True, help_text='Full URL to file in SeaweedFS', max_length=500, verbose_name='SeaweedFS URL')), + ('file_size', models.PositiveIntegerField(blank=True, null=True, verbose_name='File Size (bytes)')), + ('mime_type', models.CharField(default='image/jpeg', max_length=100, verbose_name='MIME Type')), + ('width', models.PositiveIntegerField(blank=True, null=True, verbose_name='Width (px)')), + ('height', models.PositiveIntegerField(blank=True, null=True, verbose_name='Height (px)')), + ('ai_tags', models.JSONField(blank=True, default=list, help_text='Tags generated by AI (YOLO)')), + ('ocr_text', models.TextField(blank=True, help_text='Text extracted by OCR (PaddleOCR)')), + ('exif_data', models.JSONField(blank=True, default=dict, help_text='EXIF metadata from the image')), + ('is_favorite', models.BooleanField(default=False, verbose_name='Is Favorite')), + ('taken_at', models.DateTimeField(blank=True, help_text='Date/time when photo was taken (from EXIF)', null=True, verbose_name='Taken At')), + ('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='Uploaded At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')), + ('album', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pictures', to='gallery.album', verbose_name='Album')), + ('tags', models.ManyToManyField(blank=True, help_text='User-defined tags', related_name='pictures', to='gallery.tag', verbose_name='Tags')), + ], + options={ + 'verbose_name': 'Picture', + 'verbose_name_plural': 'Pictures', + 'ordering': ['-uploaded_at'], + 'indexes': [models.Index(fields=['album', 'deleted_at'], name='gallery_pic_album_i_e4e993_idx'), models.Index(fields=['taken_at'], name='gallery_pic_taken_a_f86cb8_idx'), models.Index(fields=['is_favorite'], name='gallery_pic_is_favo_afc0f9_idx')], + }, + ), + migrations.AddIndex( + model_name='gallery', + index=models.Index(fields=['owner', 'gallery_type'], name='gallery_gal_owner_i_4b36dc_idx'), + ), + migrations.AddIndex( + model_name='gallery', + index=models.Index(fields=['deleted_at'], name='gallery_gal_deleted_df2ebd_idx'), + ), + migrations.AddIndex( + model_name='album', + index=models.Index(fields=['gallery', 'deleted_at'], name='gallery_alb_gallery_57def7_idx'), + ), + ] diff --git a/app/gallery/migrations/0002_picturetag.py b/app/gallery/migrations/0002_picturetag.py new file mode 100644 index 0000000..dfff6c0 --- /dev/null +++ b/app/gallery/migrations/0002_picturetag.py @@ -0,0 +1,53 @@ +# Generated manually for PictureTag through model + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='PictureTag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('source', models.CharField( + choices=[('user', 'User'), ('ai', 'AI')], + help_text='Whether this tag was added by user or by AI (e.g. YOLO)', + max_length=10, + verbose_name='Source', + )), + ('picture', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='tag_links', + to='gallery.picture', + verbose_name='Picture', + )), + ('tag', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='picture_tag_links', + to='gallery.tag', + verbose_name='Tag', + )), + ], + options={ + 'verbose_name': 'Picture–Tag', + 'verbose_name_plural': 'Picture–Tags', + }, + ), + migrations.AddConstraint( + model_name='picturetag', + constraint=models.UniqueConstraint( + fields=('picture', 'tag', 'source'), + name='gallery_picturetag_unique_picture_tag_source', + ), + ), + migrations.AddIndex( + model_name='picturetag', + index=models.Index(fields=['picture', 'source'], name='gallery_pictag_pic_src_idx'), + ), + ] diff --git a/app/gallery/migrations/0003_populate_picturetag.py b/app/gallery/migrations/0003_populate_picturetag.py new file mode 100644 index 0000000..4a79d32 --- /dev/null +++ b/app/gallery/migrations/0003_populate_picturetag.py @@ -0,0 +1,49 @@ +# Data migration: copy existing Picture.tags (user) and Picture.ai_tags (ai) into PictureTag + +from django.db import migrations +from django.utils.text import slugify + + +def forwards(apps, schema_editor): + Picture = apps.get_model('gallery', 'Picture') + PictureTag = apps.get_model('gallery', 'PictureTag') + Tag = apps.get_model('gallery', 'Tag') + for picture in Picture.objects.all(): + for tag in picture.tags.all(): + PictureTag.objects.get_or_create( + picture=picture, + tag=tag, + source='user', + defaults={}, + ) + for name in (picture.ai_tags or []): + name_normalized = name.lower().strip() if name else '' + if not name_normalized: + continue + slug = slugify(name_normalized) + tag, _ = Tag.objects.get_or_create( + slug=slug, + defaults={'name': name_normalized}, + ) + PictureTag.objects.get_or_create( + picture=picture, + tag=tag, + source='ai', + defaults={}, + ) + + +def backwards(apps, schema_editor): + PictureTag = apps.get_model('gallery', 'PictureTag') + PictureTag.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0002_picturetag'), + ] + + operations = [ + migrations.RunPython(forwards, backwards), + ] diff --git a/app/gallery/migrations/0004_picture_use_picturetag_through.py b/app/gallery/migrations/0004_picture_use_picturetag_through.py new file mode 100644 index 0000000..8c343c3 --- /dev/null +++ b/app/gallery/migrations/0004_picture_use_picturetag_through.py @@ -0,0 +1,34 @@ +# Remove ai_tags and switch Picture.tags to use PictureTag through model + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0003_populate_picturetag'), + ] + + operations = [ + migrations.RemoveField( + model_name='picture', + name='ai_tags', + ), + migrations.RemoveField( + model_name='picture', + name='tags', + ), + migrations.AddField( + model_name='picture', + name='tags', + field=models.ManyToManyField( + blank=True, + help_text='Tags (user and AI); use tag_links with source to distinguish', + related_name='pictures', + through='gallery.PictureTag', + to='gallery.tag', + verbose_name='Tags', + ), + ), + ] diff --git a/app/gallery/migrations/0005_rename_gallery_pictag_pic_src_idx_gallery_pic_picture_58a83a_idx.py b/app/gallery/migrations/0005_rename_gallery_pictag_pic_src_idx_gallery_pic_picture_58a83a_idx.py new file mode 100644 index 0000000..27c51d2 --- /dev/null +++ b/app/gallery/migrations/0005_rename_gallery_pictag_pic_src_idx_gallery_pic_picture_58a83a_idx.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-01-30 21:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0004_picture_use_picturetag_through'), + ] + + operations = [ + migrations.RenameIndex( + model_name='picturetag', + new_name='gallery_pic_picture_58a83a_idx', + old_name='gallery_pictag_pic_src_idx', + ), + ] diff --git a/app/gallery/migrations/__init__.py b/app/gallery/migrations/__init__.py new file mode 100644 index 0000000..4e99a83 --- /dev/null +++ b/app/gallery/migrations/__init__.py @@ -0,0 +1 @@ +# Gallery app migrations diff --git a/app/gallery/models.py b/app/gallery/models.py new file mode 100644 index 0000000..3196b1d --- /dev/null +++ b/app/gallery/models.py @@ -0,0 +1,662 @@ +""" +Gallery models: Gallery, Album, Picture, Tag +""" +from django.db import models +from django.contrib.auth import get_user_model +from django.core.validators import MinLengthValidator +from django.utils import timezone +from django.utils.text import slugify + +User = get_user_model() + + +class Tag(models.Model): + """Shared Tag model for Gallery, Album, and Picture""" + + name = models.CharField( + max_length=100, + unique=True, + db_index=True, + verbose_name='Tag Name', + help_text='Tag name (case-insensitive, normalized)' + ) + slug = models.SlugField( + max_length=100, + unique=True, + db_index=True, + verbose_name='Tag Slug', + help_text='URL-friendly tag identifier' + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name='Created At' + ) + usage_count = models.PositiveIntegerField( + default=0, + verbose_name='Usage Count', + help_text='Number of times this tag is used across all objects' + ) + + class Meta: + verbose_name = 'Tag' + verbose_name_plural = 'Tags' + ordering = ['name'] + indexes = [ + models.Index(fields=['name']), + models.Index(fields=['slug']), + ] + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + """Auto-generate slug from name""" + if not self.slug: + self.slug = slugify(self.name) + # Normalize name to lowercase for consistency + self.name = self.name.lower().strip() + super().save(*args, **kwargs) + + @classmethod + def get_or_create_tag(cls, name): + """Get or create a tag by name (case-insensitive)""" + name_normalized = name.lower().strip() + slug = slugify(name_normalized) + tag, created = cls.objects.get_or_create( + slug=slug, + defaults={'name': name_normalized} + ) + return tag, created + + def increment_usage(self): + """Increment usage count""" + Tag.objects.filter(pk=self.pk).update(usage_count=models.F('usage_count') + 1) + self.refresh_from_db() + + def decrement_usage(self): + """Decrement usage count""" + Tag.objects.filter(pk=self.pk, usage_count__gt=0).update(usage_count=models.F('usage_count') - 1) + self.refresh_from_db() + + +class PictureTag(models.Model): + """Through model for Picture–Tag with source: user-added, AI-added, or EXIF-derived.""" + class Source(models.TextChoices): + USER = 'user', 'User' + AI = 'ai', 'AI' + EXIF = 'exif', 'EXIF' + + picture = models.ForeignKey( + 'Picture', + on_delete=models.CASCADE, + related_name='tag_links', + verbose_name='Picture', + ) + tag = models.ForeignKey( + Tag, + on_delete=models.CASCADE, + related_name='picture_tag_links', + verbose_name='Tag', + ) + source = models.CharField( + max_length=10, + choices=Source.choices, + verbose_name='Source', + help_text='Whether this tag was added by user, AI (e.g. YOLO), or EXIF extraction', + ) + + class Meta: + verbose_name = 'Picture–Tag' + verbose_name_plural = 'Picture–Tags' + constraints = [ + models.UniqueConstraint( + fields=['picture', 'tag', 'source'], + name='gallery_picturetag_unique_picture_tag_source', + ), + ] + indexes = [ + models.Index(fields=['picture', 'source']), + ] + + def __str__(self): + return f'{self.picture_id} – {self.tag.name} ({self.source})' + + +class Gallery(models.Model): + """Gallery model - top level container for albums""" + + class GalleryType(models.TextChoices): + PRIVATE = 'private', 'Private' + PUBLIC = 'public', 'Public' + + owner = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='galleries', + verbose_name='Owner' + ) + name = models.CharField( + max_length=200, + validators=[MinLengthValidator(1)], + verbose_name='Gallery Name' + ) + description = models.TextField( + blank=True, + verbose_name='Description' + ) + gallery_type = models.CharField( + max_length=10, + choices=GalleryType.choices, + default=GalleryType.PRIVATE, + verbose_name='Gallery Type' + ) + tags = models.ManyToManyField( + Tag, + related_name='galleries', + blank=True, + verbose_name='Tags', + help_text='Tags for the gallery' + ) + is_favorite = models.BooleanField( + default=False, + verbose_name='Is Favorite' + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name='Created At' + ) + updated_at = models.DateTimeField( + auto_now=True, + verbose_name='Updated At' + ) + deleted_at = models.DateTimeField( + null=True, + blank=True, + verbose_name='Deleted At' + ) + + # Shared access + shared_with = models.ManyToManyField( + User, + through='GalleryShare', + related_name='shared_galleries', + blank=True, + verbose_name='Shared With' + ) + + class Meta: + verbose_name = 'Gallery' + verbose_name_plural = 'Galleries' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['owner', 'gallery_type']), + models.Index(fields=['deleted_at']), + ] + + def __str__(self): + return f"{self.name} ({self.owner.username})" + + @property + def is_deleted(self): + """Check if gallery is soft deleted""" + return self.deleted_at is not None + + def soft_delete(self): + """Soft delete the gallery""" + self.deleted_at = timezone.now() + self.save(update_fields=['deleted_at']) + + def restore(self): + """Restore soft deleted gallery""" + self.deleted_at = None + self.save(update_fields=['deleted_at']) + + def add_tag(self, tag_name): + """Add a tag to the gallery""" + tag, _ = Tag.get_or_create_tag(tag_name) + if tag not in self.tags.all(): + self.tags.add(tag) + tag.increment_usage() + + def remove_tag(self, tag_name): + """Remove a tag from the gallery""" + try: + tag = Tag.objects.get(slug=slugify(tag_name.lower().strip())) + if tag in self.tags.all(): + self.tags.remove(tag) + tag.decrement_usage() + except Tag.DoesNotExist: + raise + + @classmethod + def search_by_tags(cls, tag_names, user=None): + """Search galleries by tags""" + queryset = cls.objects.filter(deleted_at__isnull=True) + if user: + queryset = queryset.filter(owner=user) + + if tag_names: + queryset = queryset.filter(tags__name__in=tag_names).distinct() + + return queryset + + def set_tags(self, tag_names): + """Set tags from a list of tag names""" + # Remove old tags + for tag in self.tags.all(): + self.tags.remove(tag) + tag.decrement_usage() + + # Add new tags + for tag_name in tag_names: + self.add_tag(tag_name) + + +class GalleryShare(models.Model): + """Through model for sharing galleries with users""" + gallery = models.ForeignKey( + Gallery, + on_delete=models.CASCADE, + related_name='shares' + ) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='gallery_shares' + ) + shared_at = models.DateTimeField( + auto_now_add=True, + verbose_name='Shared At' + ) + can_edit = models.BooleanField( + default=False, + verbose_name='Can Edit' + ) + + class Meta: + unique_together = ['gallery', 'user'] + verbose_name = 'Gallery Share' + verbose_name_plural = 'Gallery Shares' + ordering = ['-shared_at'] + + +class Album(models.Model): + """Album model - container for pictures within a gallery""" + + gallery = models.ForeignKey( + Gallery, + on_delete=models.CASCADE, + related_name='albums', + verbose_name='Gallery' + ) + name = models.CharField( + max_length=200, + validators=[MinLengthValidator(1)], + verbose_name='Album Name' + ) + description = models.TextField( + blank=True, + verbose_name='Description' + ) + tags = models.ManyToManyField( + Tag, + related_name='albums', + blank=True, + verbose_name='Tags', + help_text='Tags for the album' + ) + exif_metadata = models.JSONField( + default=dict, + blank=True, + help_text='EXIF metadata extracted from pictures' + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name='Created At' + ) + updated_at = models.DateTimeField( + auto_now=True, + verbose_name='Updated At' + ) + deleted_at = models.DateTimeField( + null=True, + blank=True, + verbose_name='Deleted At' + ) + + class Meta: + verbose_name = 'Album' + verbose_name_plural = 'Albums' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['gallery', 'deleted_at']), + ] + + def __str__(self): + return f"{self.name} ({self.gallery.name})" + + @property + def is_deleted(self): + """Check if album is soft deleted""" + return self.deleted_at is not None + + def soft_delete(self): + """Soft delete the album""" + self.deleted_at = timezone.now() + self.save(update_fields=['deleted_at']) + + def restore(self): + """Restore soft deleted album""" + self.deleted_at = None + self.save(update_fields=['deleted_at']) + + def update_exif_metadata(self, metadata_dict): + """Update EXIF metadata, merging with existing""" + if not isinstance(self.exif_metadata, dict): + self.exif_metadata = {} + self.exif_metadata.update(metadata_dict) + self.save(update_fields=['exif_metadata']) + + def add_tag(self, tag_name): + """Add a tag to the album""" + tag, _ = Tag.get_or_create_tag(tag_name) + if tag not in self.tags.all(): + self.tags.add(tag) + tag.increment_usage() + + def remove_tag(self, tag_name): + """Remove a tag from the album""" + try: + tag = Tag.objects.get(slug=slugify(tag_name.lower().strip())) + if tag in self.tags.all(): + self.tags.remove(tag) + tag.decrement_usage() + except Tag.DoesNotExist: + pass + + @classmethod + def search_by_tags(cls, tag_names, gallery=None): + """Search albums by tags""" + queryset = cls.objects.filter(deleted_at__isnull=True) + if gallery: + queryset = queryset.filter(gallery=gallery) + + if tag_names: + queryset = queryset.filter(tags__name__in=tag_names).distinct() + + return queryset + + def set_tags(self, tag_names): + """Set tags from a list of tag names""" + # Remove old tags + for tag in self.tags.all(): + self.tags.remove(tag) + tag.decrement_usage() + + # Add new tags + for tag_name in tag_names: + self.add_tag(tag_name) + + +class Picture(models.Model): + """Picture model - individual photo stored on SeaweedFS""" + + album = models.ForeignKey( + Album, + on_delete=models.CASCADE, + related_name='pictures', + verbose_name='Album' + ) + title = models.CharField( + max_length=200, + blank=True, + verbose_name='Title' + ) + description = models.TextField( + blank=True, + verbose_name='Description' + ) + + # SeaweedFS storage + seaweedfs_file_id = models.CharField( + max_length=100, + unique=True, + verbose_name='SeaweedFS File ID', + help_text='File ID from SeaweedFS' + ) + seaweedfs_url = models.URLField( + max_length=500, + blank=True, + verbose_name='SeaweedFS URL', + help_text='Full URL to file in SeaweedFS' + ) + file_size = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name='File Size (bytes)' + ) + mime_type = models.CharField( + max_length=100, + default='image/jpeg', + verbose_name='MIME Type' + ) + width = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name='Width (px)' + ) + height = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name='Height (px)' + ) + + # AI-generated metadata (ocr_text; AI tags are in tags M2M via PictureTag.source='ai') + ocr_text = models.TextField( + blank=True, + help_text='Text extracted by OCR (PaddleOCR)' + ) + exif_data = models.JSONField( + default=dict, + blank=True, + help_text='EXIF metadata from the image' + ) + + # Tags (user and AI) via through model PictureTag with source='user' or 'ai' + tags = models.ManyToManyField( + Tag, + through=PictureTag, + related_name='pictures', + blank=True, + verbose_name='Tags', + help_text='Tags (user and AI); use tag_links with source to distinguish', + ) + is_favorite = models.BooleanField( + default=False, + verbose_name='Is Favorite' + ) + + # Timestamps + taken_at = models.DateTimeField( + null=True, + blank=True, + verbose_name='Taken At', + help_text='Date/time when photo was taken (from EXIF)' + ) + uploaded_at = models.DateTimeField( + auto_now_add=True, + verbose_name='Uploaded At' + ) + updated_at = models.DateTimeField( + auto_now=True, + verbose_name='Updated At' + ) + deleted_at = models.DateTimeField( + null=True, + blank=True, + verbose_name='Deleted At' + ) + + class Meta: + verbose_name = 'Picture' + verbose_name_plural = 'Pictures' + ordering = ['-uploaded_at'] + indexes = [ + models.Index(fields=['album', 'deleted_at']), + models.Index(fields=['taken_at']), + models.Index(fields=['is_favorite']), + ] + + def __str__(self): + return f"{self.title or 'Untitled'} ({self.album.name})" + + @property + def is_deleted(self): + """Check if picture is soft deleted""" + return self.deleted_at is not None + + def soft_delete(self): + """Soft delete the picture""" + self.deleted_at = timezone.now() + self.save(update_fields=['deleted_at']) + + def restore(self): + """Restore soft deleted picture""" + self.deleted_at = None + self.save(update_fields=['deleted_at']) + + @property + def user_tags(self): + """Tag queryset for user-added tags (source='user').""" + return Tag.objects.filter( + picture_tag_links__picture=self, + picture_tag_links__source=PictureTag.Source.USER, + ).distinct() + + @property + def ai_tags(self): + """Tag queryset for AI-added tags (source='ai').""" + return Tag.objects.filter( + picture_tag_links__picture=self, + picture_tag_links__source=PictureTag.Source.AI, + ).distinct() + + @property + def exif_tags(self): + """Tag queryset for EXIF-derived tags (source='exif').""" + return Tag.objects.filter( + picture_tag_links__picture=self, + picture_tag_links__source=PictureTag.Source.EXIF, + ).distinct() + + @property + def all_tags(self): + """List of all tag names (user + AI).""" + names = list(self.tags.values_list('name', flat=True)) + return list(dict.fromkeys(names)) # preserve order, dedupe + + def add_tag(self, tag_name): + """Add a user-defined tag to the picture (source='user').""" + tag, _ = Tag.get_or_create_tag(tag_name) + _, created = PictureTag.objects.get_or_create( + picture=self, + tag=tag, + source=PictureTag.Source.USER, + defaults={}, + ) + if created: + tag.increment_usage() + + def remove_tag(self, tag_name): + """Remove a user-defined tag from the picture.""" + try: + tag = Tag.objects.get(slug=slugify(tag_name.lower().strip())) + deleted = PictureTag.objects.filter( + picture=self, + tag=tag, + source=PictureTag.Source.USER, + ).delete() + if deleted[0]: + tag.decrement_usage() + except Tag.DoesNotExist: + pass + + @classmethod + def search_by_tags(cls, tag_names, album=None, user=None): + """Search pictures by tags (any source).""" + queryset = cls.objects.filter(deleted_at__isnull=True) + if album: + queryset = queryset.filter(album=album) + if user: + queryset = queryset.filter(album__gallery__owner=user) + if tag_names: + queryset = queryset.filter(tags__name__in=tag_names).distinct() + return queryset + + def set_tags(self, tag_names): + """Set user tags from a list of tag names (replaces existing user tags).""" + # Remove existing user tag links and decrement usage + for pt in PictureTag.objects.filter(picture=self, source=PictureTag.Source.USER).select_related('tag'): + pt.tag.decrement_usage() + PictureTag.objects.filter(picture=self, source=PictureTag.Source.USER).delete() + # Add new user tags + for tag_name in tag_names: + self.add_tag(tag_name) + + def add_ai_tag(self, tag_name): + """Add an AI-generated tag (source='ai').""" + tag, _ = Tag.get_or_create_tag(tag_name) + created = PictureTag.objects.get_or_create( + picture=self, + tag=tag, + source=PictureTag.Source.AI, + defaults={}, + )[1] + if created: + tag.increment_usage() + + def remove_ai_tag(self, tag_name): + """Remove an AI-generated tag.""" + try: + tag = Tag.objects.get(slug=slugify(tag_name.lower().strip())) + deleted = PictureTag.objects.filter( + picture=self, + tag=tag, + source=PictureTag.Source.AI, + ).delete() + if deleted[0]: + tag.decrement_usage() + except Tag.DoesNotExist: + pass + + def set_ai_tags(self, tag_names): + """Replace AI tags with the given list of tag names (e.g. from YOLO).""" + for pt in PictureTag.objects.filter(picture=self, source=PictureTag.Source.AI).select_related('tag'): + pt.tag.decrement_usage() + PictureTag.objects.filter(picture=self, source=PictureTag.Source.AI).delete() + for tag_name in (tag_names or []): + name = (tag_name or '').strip() + if name: + self.add_ai_tag(name) + + def add_exif_tag(self, tag_name): + """Add an EXIF-derived tag (source='exif').""" + tag, _ = Tag.get_or_create_tag(tag_name) + created = PictureTag.objects.get_or_create( + picture=self, + tag=tag, + source=PictureTag.Source.EXIF, + defaults={}, + )[1] + if created: + tag.increment_usage() + + def set_exif_tags(self, tag_names): + """Replace EXIF tags with the given list of tag names (e.g. from EXIF extraction).""" + for pt in PictureTag.objects.filter(picture=self, source=PictureTag.Source.EXIF).select_related('tag'): + pt.tag.decrement_usage() + PictureTag.objects.filter(picture=self, source=PictureTag.Source.EXIF).delete() + for tag_name in (tag_names or []): + name = (tag_name or '').strip() + if name: + self.add_exif_tag(name) diff --git a/app/gallery/serializers.py b/app/gallery/serializers.py new file mode 100644 index 0000000..67f8d66 --- /dev/null +++ b/app/gallery/serializers.py @@ -0,0 +1,215 @@ +""" +DRF serializers for Gallery API +""" +from rest_framework import serializers +from django.contrib.auth import get_user_model +from .models import Gallery, Album, Picture, GalleryShare, Tag +from .utils import generate_signed_url + +User = get_user_model() + + +class TagSerializer(serializers.ModelSerializer): + """Serializer for Tag model""" + class Meta: + model = Tag + fields = ['id', 'name', 'slug', 'usage_count'] + read_only_fields = ['slug', 'usage_count'] + + +class TagListField(serializers.Field): + """Custom field to handle tags as list of names""" + def to_representation(self, value): + """Convert Tag queryset to list of tag names""" + return [tag.name for tag in value.all()] + + def to_internal_value(self, data): + """Convert list of tag names to Tag objects""" + if data is None: + return [] + if not isinstance(data, list): + raise serializers.ValidationError("Tags must be a list of tag names") + return data + + +class PictureUserTagsField(serializers.Field): + """Read-write field for Picture user tags (source='user').""" + def get_attribute(self, instance): + return instance # pass picture so to_representation gets it + def to_representation(self, instance): + return [tag.name for tag in instance.user_tags] + def to_internal_value(self, data): + if data is None: + return [] + if not isinstance(data, list): + raise serializers.ValidationError("Tags must be a list of tag names") + return [str(x).strip() for x in data if str(x).strip()] + + +class PictureSerializer(serializers.ModelSerializer): + """Serializer for Picture model with signed URL""" + signed_url = serializers.SerializerMethodField() + all_tags = serializers.SerializerMethodField() + tags = PictureUserTagsField() + ai_tags = serializers.SerializerMethodField() + album_name = serializers.CharField(source='album.name', read_only=True) + + class Meta: + model = Picture + fields = [ + 'id', 'title', 'description', 'album', 'album_name', + 'seaweedfs_file_id', 'signed_url', 'file_size', 'mime_type', + 'width', 'height', 'ai_tags', 'ocr_text', 'exif_data', + 'tags', 'all_tags', 'is_favorite', 'taken_at', + 'uploaded_at', 'updated_at' + ] + read_only_fields = [ + 'seaweedfs_file_id', 'signed_url', 'file_size', 'mime_type', + 'width', 'height', 'uploaded_at', 'updated_at' + ] + + def get_signed_url(self, obj): + """Generate signed URL for the picture""" + if not obj.seaweedfs_file_id: + return None + try: + signed = generate_signed_url(obj.seaweedfs_file_id) + return signed['url'] + except Exception: + return None + + def get_ai_tags(self, obj): + """AI-added tag names (e.g. from YOLO)""" + return [tag.name for tag in obj.ai_tags] + + def get_all_tags(self, obj): + """Get all tag names (user + AI)""" + return obj.all_tags + + def update(self, instance, validated_data): + """Handle tag updates""" + tags_data = validated_data.pop('tags', None) + instance = super().update(instance, validated_data) + + if tags_data is not None: + instance.set_tags(tags_data) + + return instance + + def create(self, validated_data): + """Handle tag creation""" + tags_data = validated_data.pop('tags', []) + instance = super().create(validated_data) + + if tags_data: + instance.set_tags(tags_data) + + return instance + + +class AlbumSerializer(serializers.ModelSerializer): + """Serializer for Album model""" + picture_count = serializers.SerializerMethodField() + tags = TagListField() + gallery_name = serializers.CharField(source='gallery.name', read_only=True) + + class Meta: + model = Album + fields = [ + 'id', 'name', 'description', 'gallery', 'gallery_name', + 'tags', 'exif_metadata', 'picture_count', + 'created_at', 'updated_at' + ] + read_only_fields = ['created_at', 'updated_at'] + + def get_picture_count(self, obj): + """Get count of non-deleted pictures""" + return obj.pictures.filter(deleted_at__isnull=True).count() + + def update(self, instance, validated_data): + """Handle tag updates""" + tags_data = validated_data.pop('tags', None) + instance = super().update(instance, validated_data) + + if tags_data is not None: + instance.set_tags(tags_data) + + return instance + + def create(self, validated_data): + """Handle tag creation""" + tags_data = validated_data.pop('tags', []) + instance = super().create(validated_data) + + if tags_data: + instance.set_tags(tags_data) + + return instance + + +class AlbumDetailSerializer(AlbumSerializer): + """Detailed album serializer with pictures""" + pictures = PictureSerializer(many=True, read_only=True) + + class Meta(AlbumSerializer.Meta): + fields = AlbumSerializer.Meta.fields + ['pictures'] + + +class GalleryShareSerializer(serializers.ModelSerializer): + """Serializer for GalleryShare""" + user_email = serializers.EmailField(source='user.email', read_only=True) + user_username = serializers.CharField(source='user.username', read_only=True) + + class Meta: + model = GalleryShare + fields = ['id', 'user', 'user_email', 'user_username', 'can_edit', 'shared_at'] + read_only_fields = ['shared_at'] + + +class GallerySerializer(serializers.ModelSerializer): + """Serializer for Gallery model""" + owner_username = serializers.CharField(source='owner.username', read_only=True) + album_count = serializers.SerializerMethodField() + tags = TagListField(required=False, allow_null=True) + shared_with_users = GalleryShareSerializer(source='shares', many=True, read_only=True) + + class Meta: + model = Gallery + fields = [ + 'id', 'name', 'description', 'owner', 'owner_username', + 'gallery_type', 'tags', 'is_favorite', 'album_count', + 'shared_with_users', 'created_at', 'updated_at' + ] + read_only_fields = ['owner', 'created_at', 'updated_at'] + + def get_album_count(self, obj): + """Get count of non-deleted albums""" + return obj.albums.filter(deleted_at__isnull=True).count() + + def update(self, instance, validated_data): + """Handle tag updates""" + tags_data = validated_data.pop('tags', None) + instance = super().update(instance, validated_data) + + if tags_data is not None: + instance.set_tags(tags_data) + + return instance + + def create(self, validated_data): + """Handle tag creation""" + tags_data = validated_data.pop('tags', []) + instance = super().create(validated_data) + + if tags_data: + instance.set_tags(tags_data) + + return instance + + +class GalleryDetailSerializer(GallerySerializer): + """Detailed gallery serializer with albums""" + albums = AlbumSerializer(many=True, read_only=True) + + class Meta(GallerySerializer.Meta): + fields = GallerySerializer.Meta.fields + ['albums'] diff --git a/app/gallery/tasks.py b/app/gallery/tasks.py new file mode 100644 index 0000000..4469034 --- /dev/null +++ b/app/gallery/tasks.py @@ -0,0 +1,227 @@ +""" +Celery tasks for the gallery app. + +- GPU task: process_picture_ai — YOLO (ai_tags) + PaddleOCR (ocr_text). Route to 'gpu'. +- CPU task: extract_picture_exif — EXIF metadata (camera, location, etc.) as tags. Route to 'cpu'. +""" +import io +import logging +from datetime import datetime + +from django.core.files.storage import default_storage +from django.utils import timezone + +from config.celery import app + +logger = logging.getLogger(__name__) + +# EXIF tag IDs (Pillow / standard EXIF) +EXIF_MAKE = 271 +EXIF_MODEL = 272 +EXIF_DATETIME_ORIGINAL = 36867 +EXIF_GPS_INFO = 34853 + + +def _run_yolo(image_bytes): + """Run YOLO object detection on image bytes. Returns list of detected class names (ai_tags).""" + try: + from ultralytics import YOLO + import numpy as np + from PIL import Image + except ImportError: + logger.debug("ultralytics not available, skipping YOLO") + return [] + try: + img = Image.open(io.BytesIO(image_bytes)).convert('RGB') + img_np = np.array(img) + model = YOLO('yolov8n.pt') + results = model.predict(source=img_np, verbose=False) + tags = [] + for r in results: + if r.boxes is not None and r.names: + for cls_id in r.boxes.cls.int().tolist(): + name = r.names.get(int(cls_id)) + if name and name not in tags: + tags.append(name) + return tags + except Exception as e: + logger.warning("YOLO inference failed: %s", e) + return [] + + +def _run_paddleocr(image_bytes): + """Run PaddleOCR on image bytes. Returns extracted text string (ocr_text).""" + try: + from paddleocr import PaddleOCR + import numpy as np + from PIL import Image + except ImportError as e: + logger.debug("paddleocr/PIL not available, skipping OCR: %s", e) + return "" + try: + img = Image.open(io.BytesIO(image_bytes)).convert('RGB') + img_np = np.array(img) + ocr = PaddleOCR(use_textline_orientation=True, lang='en') + result = ocr.predict(img_np) + if not result or not result[0]: + return "" + lines = [] + for line in result[0]: + if line and len(line) >= 2 and line[1]: + lines.append(line[1][0]) + return "\n".join(lines).strip() if lines else "" + except Exception as e: + logger.warning("PaddleOCR failed: %s", e, exc_info=True) + return "" + + +@app.task(bind=True, queue='gpu') +def process_picture_ai(self, picture_id): + """ + Extract objects (YOLO → ai_tags) and text (PaddleOCR → ocr_text) for a picture. + Expects to run on GPU worker. Picture image is read from default_storage using + picture.seaweedfs_file_id. + """ + from .models import Picture + + try: + picture = Picture.objects.get(pk=picture_id, deleted_at__isnull=True) + except Picture.DoesNotExist: + logger.warning("Picture %s not found or deleted, skipping AI processing", picture_id) + return + + path = picture.seaweedfs_file_id + if not path: + logger.warning("Picture %s has no seaweedfs_file_id, skipping AI processing", picture_id) + return + + try: + with default_storage.open(path, 'rb') as f: + image_bytes = f.read() + except Exception as e: + logger.warning("Could not open image for picture %s: %s", picture_id, e) + return + + if not image_bytes: + return + + ai_tag_names = _run_yolo(image_bytes) + ocr_text = _run_paddleocr(image_bytes) + + if ai_tag_names is not None: + picture.set_ai_tags(ai_tag_names) + if ocr_text is not None: + picture.ocr_text = ocr_text + picture.save(update_fields=['ocr_text']) + logger.info("Picture %s: ai_tags=%s, ocr_text length=%s", picture_id, len(ai_tag_names or []), len(ocr_text or '')) + + +def _extract_exif_tags_and_metadata(image_bytes): + """ + Extract EXIF-derived tag names and metadata from image bytes. + Returns (tag_names_list, exif_data_dict, taken_at_datetime_or_None). + Uses Pillow; no GPU. Tag names are lowercase for consistency (e.g. make:canon, model:eos r5, gps). + """ + try: + from PIL import Image + from PIL.ExifTags import TAGS + except ImportError: + logger.debug("PIL not available for EXIF") + return [], {}, None + + try: + img = Image.open(io.BytesIO(image_bytes)) + exif = img.getexif() if hasattr(img, 'getexif') else None + if not exif: + return [], {}, None + except Exception as e: + logger.debug("Could not open image for EXIF: %s", e) + return [], {}, None + + tag_names = [] + exif_data = {} + taken_at = None + + # Make (271) + make = exif.get(EXIF_MAKE) + if make and isinstance(make, str): + make_clean = make.strip().lower() + if make_clean: + tag_names.append(f"make:{make_clean}") + exif_data['make'] = make + + # Model (272) + model = exif.get(EXIF_MODEL) + if model and isinstance(model, str): + model_clean = model.strip().lower() + if model_clean: + tag_names.append(f"model:{model_clean}") + exif_data['model'] = model + + # Camera as single tag (e.g. "canon eos r5") + if make and model and isinstance(make, str) and isinstance(model, str): + camera = f"{make} {model}".strip().lower() + if camera and camera not in [t for t in tag_names if t.startswith("make:") or t.startswith("model:")]: + tag_names.append(f"camera:{camera}") + + # DateTimeOriginal (36867) -> taken_at + dt_orig = exif.get(EXIF_DATETIME_ORIGINAL) + if dt_orig and isinstance(dt_orig, str): + exif_data['datetime_original'] = dt_orig + try: + # EXIF format: "2024:01:15 14:30:00" + taken_at = datetime.strptime(dt_orig, "%Y:%m:%d %H:%M:%S") + taken_at = timezone.make_aware(taken_at) if timezone.is_naive(taken_at) else taken_at + except (ValueError, TypeError): + pass + + # GPS (34853) -> tag "gps" and store presence + gps = exif.get(EXIF_GPS_INFO) + if gps is not None: + tag_names.append("gps") + exif_data['has_gps'] = True + + return tag_names, exif_data, taken_at + + +@app.task(bind=True, queue='cpu') +def extract_picture_exif(self, picture_id): + """ + Extract EXIF metadata from a picture and add as tags (camera, location/gps, etc.). + Runs on CPU worker. Reads image from default_storage using picture.seaweedfs_file_id. + """ + from .models import Picture + + try: + picture = Picture.objects.get(pk=picture_id, deleted_at__isnull=True) + except Picture.DoesNotExist: + logger.warning("Picture %s not found or deleted, skipping EXIF extraction", picture_id) + return + + path = picture.seaweedfs_file_id + if not path: + logger.warning("Picture %s has no seaweedfs_file_id, skipping EXIF extraction", picture_id) + return + + try: + with default_storage.open(path, 'rb') as f: + image_bytes = f.read() + except Exception as e: + logger.warning("Could not open image for picture %s (EXIF): %s", picture_id, e) + return + + if not image_bytes: + return + + tag_names, exif_data, taken_at = _extract_exif_tags_and_metadata(image_bytes) + + if exif_data: + picture.exif_data = {**(picture.exif_data or {}), **exif_data} + picture.save(update_fields=['exif_data']) + if taken_at is not None: + picture.taken_at = taken_at + picture.save(update_fields=['taken_at']) + if tag_names: + picture.set_exif_tags(tag_names) + + logger.info("Picture %s: exif_tags=%s", picture_id, len(tag_names)) diff --git a/app/gallery/template_views.py b/app/gallery/template_views.py new file mode 100644 index 0000000..846ce3a --- /dev/null +++ b/app/gallery/template_views.py @@ -0,0 +1,706 @@ +""" +Template-based views for Gallery app with HTMX support +""" +from django.shortcuts import render, get_object_or_404, redirect +from django.contrib.auth.decorators import login_required +from django.contrib import messages +from django.db.models import Q, Count +from django.http import HttpResponse, JsonResponse +from django.views.decorators.http import require_http_methods +from django.template.loader import render_to_string +from .models import Gallery, Album, Picture, Tag +from .forms import GalleryForm, AlbumForm, PictureUploadForm, PictureEditForm +from .utils import generate_signed_url, upload_picture_file, extract_images_from_archive +import logging + +try: + from .tasks import process_picture_ai, extract_picture_exif +except ImportError: + process_picture_ai = None + extract_picture_exif = None + +logger = logging.getLogger(__name__) + +@login_required +def gallery_list(request): + """List all galleries for the user; when search is present, also return albums and pictures matching the query.""" + user = request.user + shared = request.GET.get('shared') == '1' + search = request.GET.get('search', '').strip() + + if shared: + gallery_qs = Gallery.objects.filter(shared_with=user, deleted_at__isnull=True).distinct() + else: + gallery_qs = Gallery.objects.filter(owner=user, deleted_at__isnull=True) + + if search: + gallery_qs = gallery_qs.filter( + Q(name__icontains=search) + | Q(description__icontains=search) + | Q(tags__name__icontains=search) + ).distinct() + + gallery_type = request.GET.get('gallery_type') + if gallery_type: + gallery_qs = gallery_qs.filter(gallery_type=gallery_type) + + galleries = gallery_qs.annotate( + album_count=Count('albums', filter=Q(albums__deleted_at__isnull=True), distinct=True) + ).prefetch_related('tags', 'albums').order_by('-created_at') + + search_albums = None + search_pictures = None + if search: + if shared: + album_qs = Album.objects.filter(gallery__shared_with=user, deleted_at__isnull=True) + else: + album_qs = Album.objects.filter(gallery__owner=user, deleted_at__isnull=True) + search_albums = album_qs.filter( + Q(name__icontains=search) + | Q(description__icontains=search) + | Q(tags__name__icontains=search) + ).distinct().select_related('gallery').prefetch_related('tags').order_by('-created_at') + + if shared: + picture_qs = Picture.objects.filter(album__gallery__shared_with=user, deleted_at__isnull=True) + else: + picture_qs = Picture.objects.filter(album__gallery__owner=user, deleted_at__isnull=True) + search_pictures = picture_qs.filter( + Q(title__icontains=search) + | Q(description__icontains=search) + | Q(ocr_text__icontains=search) + | Q(tags__name__icontains=search) + ).distinct().select_related('album', 'album__gallery').prefetch_related('tags').order_by('-uploaded_at') + for picture in search_pictures: + if picture.seaweedfs_file_id: + try: + signed = generate_signed_url(picture.seaweedfs_file_id) + picture.signed_url = signed['url'] + except Exception: + picture.signed_url = None + else: + picture.signed_url = None + + context = { + 'galleries': galleries, + 'search_query': search or None, + 'search_albums': search_albums, + 'search_pictures': search_pictures, + } + return render(request, 'gallery/gallery_list.html', context) + + +@login_required +def gallery_detail(request, pk): + """View gallery details""" + gallery = get_object_or_404( + Gallery.objects.filter( + Q(owner=request.user) | Q(shared_with=request.user), + deleted_at__isnull=True + ), + pk=pk + ) + + # Prefetch albums with pictures; annotate with count of non-deleted pictures + albums = gallery.albums.filter(deleted_at__isnull=True).annotate( + picture_count=Count('pictures', filter=Q(pictures__deleted_at__isnull=True), distinct=True) + ).prefetch_related('pictures', 'tags') + + context = { + 'gallery': gallery, + 'albums': albums, + } + return render(request, 'gallery/gallery_detail.html', context) + + +@login_required +def gallery_create(request): + """Create a new gallery""" + if request.method == 'POST': + form = GalleryForm(request.POST) + if form.is_valid(): + gallery = form.save(commit=False) + gallery.owner = request.user + gallery.save() + + # Handle tags + tags_str = request.POST.get('tags', '') + if tags_str: + tag_names = [tag.strip() for tag in tags_str.split(',') if tag.strip()] + gallery.set_tags(tag_names) + + messages.success(request, f'Gallery "{gallery.name}" created successfully!') + return redirect('gallery:gallery_detail', pk=gallery.id) + else: + form = GalleryForm() + + context = {'form': form} + return render(request, 'gallery/gallery_form.html', context) + + +@login_required +def gallery_edit(request, pk): + """Edit a gallery""" + gallery = get_object_or_404(Gallery, pk=pk, owner=request.user, deleted_at__isnull=True) + + if request.method == 'POST': + form = GalleryForm(request.POST, instance=gallery) + if form.is_valid(): + gallery = form.save() + messages.success(request, f'Gallery "{gallery.name}" updated successfully!') + return redirect('gallery:gallery_detail', pk=gallery.id) + else: + form = GalleryForm(instance=gallery) + + context = {'form': form, 'gallery': gallery} + return render(request, 'gallery/gallery_form.html', context) + + +@login_required +@require_http_methods(["POST"]) +def gallery_toggle_favorite(request, pk): + """Toggle favorite status (HTMX endpoint)""" + gallery = get_object_or_404( + Gallery.objects.filter( + Q(owner=request.user) | Q(shared_with=request.user), + deleted_at__isnull=True + ), + pk=pk + ) + + gallery.is_favorite = not gallery.is_favorite + gallery.save(update_fields=['is_favorite']) + + # Return updated button HTML for HTMX + button_html = render_to_string('gallery/partials/favorite_button.html', { + 'object': gallery, + 'object_type': 'gallery', + 'object_id': gallery.id, + }, request=request) + + return HttpResponse(button_html) + + +@login_required +@require_http_methods(["POST"]) +def gallery_add_tag(request, pk): + """Add tag to gallery (HTMX endpoint)""" + gallery = get_object_or_404( + Gallery.objects.filter( + Q(owner=request.user) | Q(shared_with=request.user), + deleted_at__isnull=True + ), + pk=pk + ) + + tag_name = request.POST.get('tag', '').strip() + if tag_name: + gallery.add_tag(tag_name) + + # Return updated tags HTML for HTMX + tags_html = render_to_string('gallery/partials/tags_list.html', { + 'object': gallery, + 'object_type': 'gallery', + 'object_id': gallery.id, + }, request=request) + + return HttpResponse(tags_html) + + +@login_required +@require_http_methods(["POST", "DELETE"]) +def gallery_remove_tag(request, pk): + """Remove tag from gallery (HTMX endpoint)""" + gallery = get_object_or_404( + Gallery.objects.filter( + Q(owner=request.user) | Q(shared_with=request.user), + deleted_at__isnull=True + ), + pk=pk + ) + + # HTMX sends POST with hx-vals in body (DELETE would not populate request.POST) + tag_name = request.POST.get('tag', '').strip() + if tag_name: + gallery.remove_tag(tag_name) + + # Return empty string to remove the tag element + return HttpResponse('') + + +@login_required +@require_http_methods(["POST"]) +def gallery_delete(request, pk): + """Soft-delete a single gallery and redirect to gallery list.""" + gallery = get_object_or_404( + Gallery.objects.filter( + Q(owner=request.user) | Q(shared_with=request.user), + deleted_at__isnull=True + ), + pk=pk + ) + gallery.soft_delete() + messages.success(request, f'Gallery "{gallery.name}" deleted.') + return redirect('gallery:gallery_list') + + +@login_required +@require_http_methods(["POST"]) +def gallery_bulk_delete(request): + """Soft-delete selected galleries (bulk operation).""" + ids = request.POST.getlist('ids') + if not ids: + messages.warning(request, 'No galleries selected.') + return redirect('gallery:gallery_list') + galleries = Gallery.objects.filter( + Q(owner=request.user) | Q(shared_with=request.user), + deleted_at__isnull=True, + pk__in=ids + ) + count = galleries.count() + for gallery in galleries: + gallery.soft_delete() + if count == 1: + messages.success(request, '1 gallery deleted.') + else: + messages.success(request, f'{count} galleries deleted.') + return redirect('gallery:gallery_list') + + +@login_required +def album_detail(request, pk): + """View album details""" + album = get_object_or_404( + Album.objects.filter( + Q(gallery__owner=request.user) | Q(gallery__shared_with=request.user), + deleted_at__isnull=True + ), + pk=pk + ) + + # Generate signed URLs for pictures (set on same objects passed to template) + pictures = album.pictures.filter(deleted_at__isnull=True).prefetch_related('tags') + for picture in pictures: + if picture.seaweedfs_file_id: + try: + signed = generate_signed_url(picture.seaweedfs_file_id) + picture.signed_url = signed['url'] + except Exception: + picture.signed_url = None + else: + picture.signed_url = None + context = { + 'album': album, + 'pictures': pictures, + } + return render(request, 'gallery/album_detail.html', context) + + +@login_required +def album_create(request, gallery_id): + """Create a new album""" + gallery = get_object_or_404( + Gallery.objects.filter( + Q(owner=request.user) | Q(shared_with=request.user), + deleted_at__isnull=True + ), + pk=gallery_id + ) + + if request.method == 'POST': + form = AlbumForm(request.POST, gallery=gallery) + if form.is_valid(): + album = form.save() + + # Handle tags + tags_str = request.POST.get('tags', '') + if tags_str: + tag_names = [tag.strip() for tag in tags_str.split(',') if tag.strip()] + album.set_tags(tag_names) + + messages.success(request, f'Album "{album.name}" created successfully!') + return redirect('gallery:album_detail', pk=album.id) + else: + form = AlbumForm(gallery=gallery) + + context = {'form': form, 'gallery': gallery} + return render(request, 'gallery/album_form.html', context) + + +@login_required +def album_edit(request, pk): + """Edit an album""" + album = get_object_or_404( + Album.objects.filter( + Q(gallery__owner=request.user) | Q(gallery__shared_with=request.user), + deleted_at__isnull=True + ), + pk=pk + ) + + if request.method == 'POST': + form = AlbumForm(request.POST, instance=album) + if form.is_valid(): + album = form.save() + messages.success(request, f'Album "{album.name}" updated successfully!') + return redirect('gallery:album_detail', pk=album.id) + else: + form = AlbumForm(instance=album) + + context = {'form': form, 'album': album, 'gallery': album.gallery} + return render(request, 'gallery/album_form.html', context) + + +@login_required +@require_http_methods(["POST"]) +def album_add_tag(request, pk): + """Add tag to album (HTMX endpoint)""" + album = get_object_or_404( + Album.objects.filter( + Q(gallery__owner=request.user) | Q(gallery__shared_with=request.user), + deleted_at__isnull=True + ), + pk=pk + ) + + tag_name = request.POST.get('tag', '').strip() + if tag_name: + album.add_tag(tag_name) + + # Return updated tags HTML for HTMX + tags_html = render_to_string('gallery/partials/tags_list.html', { + 'object': album, + 'object_type': 'album', + 'object_id': album.id, + }, request=request) + + return HttpResponse(tags_html) + + +@login_required +@require_http_methods(["POST", "DELETE"]) +def album_remove_tag(request, pk): + """Remove tag from album (HTMX endpoint)""" + album = get_object_or_404( + Album.objects.filter( + Q(gallery__owner=request.user) | Q(gallery__shared_with=request.user), + deleted_at__isnull=True + ), + pk=pk + ) + + # HTMX sends DELETE but Django forms send POST + tag_name = request.POST.get('tag', '').strip() + if tag_name: + album.remove_tag(tag_name) + + # Return empty string to remove the tag element + return HttpResponse('') + + +@login_required +@require_http_methods(["POST"]) +def album_delete(request, pk): + """Soft-delete a single album and redirect to gallery.""" + album = get_object_or_404( + Album.objects.filter( + Q(gallery__owner=request.user) | Q(gallery__shared_with=request.user), + deleted_at__isnull=True + ), + pk=pk + ) + gallery_id = album.gallery_id + album.soft_delete() + messages.success(request, f'Album "{album.name}" deleted.') + return redirect('gallery:gallery_detail', pk=gallery_id) + + +@login_required +@require_http_methods(["POST"]) +def album_bulk_delete(request, pk): + """Soft-delete selected albums in a gallery (bulk operation).""" + gallery = get_object_or_404( + Gallery.objects.filter( + Q(owner=request.user) | Q(shared_with=request.user), + deleted_at__isnull=True + ), + pk=pk + ) + ids = request.POST.getlist('ids') + if not ids: + messages.warning(request, 'No albums selected.') + return redirect('gallery:gallery_detail', pk=pk) + albums = gallery.albums.filter(deleted_at__isnull=True, pk__in=ids) + count = albums.count() + for album in albums: + album.soft_delete() + if count == 1: + messages.success(request, '1 album deleted.') + else: + messages.success(request, f'{count} albums deleted.') + return redirect('gallery:gallery_detail', pk=pk) + + +def _process_uploaded_image(uploaded_file, album, form_cleaned_data): + """Process a single uploaded image: get dimensions, upload to storage, create Picture, add tags.""" + width, height = None, None + try: + from PIL import Image + img = Image.open(uploaded_file) + width, height = img.size + uploaded_file.seek(0) + except Exception: + pass + + file_id = upload_picture_file( + uploaded_file, + album_id=album.id, + content_type=getattr(uploaded_file, 'content_type', None), + ) + title = form_cleaned_data.get('title') or (getattr(uploaded_file, 'name', '') or 'Untitled') + if title == 'Untitled' and getattr(uploaded_file, 'name', ''): + title = uploaded_file.name + + picture = Picture( + album=album, + title=title, + description=form_cleaned_data.get('description', ''), + seaweedfs_file_id=file_id, + file_size=getattr(uploaded_file, 'size', 0) or 0, + mime_type=getattr(uploaded_file, 'content_type', 'image/jpeg') or 'image/jpeg', + width=width, + height=height, + ) + picture.save() + + tags_str = form_cleaned_data.get('tags', '') + if tags_str: + tag_names = [t.strip() for t in tags_str.split(',') if t.strip()] + for tag_name in tag_names: + picture.add_tag(tag_name) + return picture + + +@login_required +def picture_upload(request, album_id): + """Upload one or more pictures, or an archive (ZIP/TAR), to an album.""" + album = get_object_or_404( + Album.objects.filter( + Q(gallery__owner=request.user) | Q(gallery__shared_with=request.user), + deleted_at__isnull=True + ), + pk=album_id + ) + + if request.method == 'POST': + form = PictureUploadForm(request.POST, request.FILES, album=album) + if form.is_valid(): + images_to_process = [] + + # Collect multiple files from input name="files" + for f in request.FILES.getlist('files'): + if f and getattr(f, 'size', 0): + images_to_process.append(f) + + # Single file from input name="file" (backward compat) + single = request.FILES.get('file') + if single and getattr(single, 'size', 0): + images_to_process.append(single) + + # Archive: extract images + archive = request.FILES.get('archive') + if archive and getattr(archive, 'size', 0): + try: + for _filename, file_obj in extract_images_from_archive(archive): + images_to_process.append(file_obj) + except ValueError as e: + messages.error(request, str(e)) + context = {'form': form, 'album': album} + return render(request, 'gallery/picture_upload.html', context) + except Exception as e: + messages.error(request, f'Failed to read archive: {e}') + context = {'form': form, 'album': album} + return render(request, 'gallery/picture_upload.html', context) + + if not images_to_process: + messages.error(request, 'Please select one or more images, or upload a ZIP/TAR archive.') + context = {'form': form, 'album': album} + return render(request, 'gallery/picture_upload.html', context) + + created = [] + failed = [] + for uploaded_file in images_to_process: + try: + picture = _process_uploaded_image(uploaded_file, album, form.cleaned_data) + created.append(picture) + except Exception as e: + failed.append((getattr(uploaded_file, 'name', '?'), str(e))) + + if failed: + for name, err in failed: + messages.error(request, f'Failed to upload {name}: {err}') + if created: + extract_with_ai = form.cleaned_data.get('extract_with_ai', False) + extract_with_exif = form.cleaned_data.get('extract_with_exif', False) + if extract_with_ai and process_picture_ai: + for picture in created: + process_picture_ai.apply_async(args=[picture.id], queue='gpu') + if extract_with_exif and extract_picture_exif: + for picture in created: + extract_picture_exif.apply_async(args=[picture.id], queue='cpu') + msg = f'{len(created)} picture(s) uploaded successfully.' + if len(created) == 1: + msg = f'Picture "{created[0].title}" uploaded successfully!' + messages.success(request, msg) + return redirect('gallery:album_detail', pk=album.id) + # All failed + context = {'form': form, 'album': album} + return render(request, 'gallery/picture_upload.html', context) + else: + form = PictureUploadForm(album=album) + + context = {'form': form, 'album': album} + return render(request, 'gallery/picture_upload.html', context) + + +@login_required +def picture_detail(request, pk): + """View picture details""" + picture = get_object_or_404( + Picture.objects.filter( + Q(album__gallery__owner=request.user) | Q(album__gallery__shared_with=request.user), + deleted_at__isnull=True + ), + pk=pk + ) + + # Generate signed URL + signed_url = None + if picture.seaweedfs_file_id: + try: + signed = generate_signed_url(picture.seaweedfs_file_id) + signed_url = signed['url'] + except Exception: + pass + + context = { + 'picture': picture, + 'signed_url': signed_url, + } + return render(request, 'gallery/picture_detail.html', context) + + +@login_required +def picture_edit(request, pk): + """Edit picture title, description, and tags.""" + picture = get_object_or_404( + Picture.objects.filter( + Q(album__gallery__owner=request.user) | Q(album__gallery__shared_with=request.user), + deleted_at__isnull=True + ), + pk=pk + ) + if request.method == 'POST': + form = PictureEditForm(request.POST, instance=picture) + if form.is_valid(): + form.save() + messages.success(request, f'Picture "{picture.title or "Untitled"}" updated successfully!') + return redirect('gallery:picture_detail', pk=picture.id) + else: + form = PictureEditForm(instance=picture) + context = {'form': form, 'picture': picture} + return render(request, 'gallery/picture_edit.html', context) + + +@login_required +@require_http_methods(["POST"]) +def picture_reprocess_ai(request, pk): + """Re-run YOLO + PaddleOCR on this picture (e.g. after model update). Queues task and redirects back.""" + picture = get_object_or_404( + Picture.objects.filter( + Q(album__gallery__owner=request.user) | Q(album__gallery__shared_with=request.user), + deleted_at__isnull=True + ), + pk=pk + ) + if process_picture_ai and picture.seaweedfs_file_id: + process_picture_ai.apply_async(args=[picture.id], queue='gpu') + messages.success(request, 'Re-run queued. AI tags and OCR text will update shortly.') + else: + if not picture.seaweedfs_file_id: + messages.warning(request, 'Picture has no image file; cannot re-run AI.') + else: + messages.warning(request, 'AI processing is not configured.') + return redirect('gallery:picture_detail', pk=pk) + + +@login_required +@require_http_methods(["POST"]) +def picture_toggle_favorite(request, pk): + """Toggle favorite status for picture (HTMX endpoint)""" + picture = get_object_or_404( + Picture.objects.filter( + Q(album__gallery__owner=request.user) | Q(album__gallery__shared_with=request.user), + deleted_at__isnull=True + ), + pk=pk + ) + + picture.is_favorite = not picture.is_favorite + picture.save(update_fields=['is_favorite']) + + # Return updated button HTML for HTMX + button_html = render_to_string('gallery/partials/favorite_button.html', { + 'object': picture, + 'object_type': 'picture', + 'object_id': picture.id, + }, request=request) + + return HttpResponse(button_html) + + +@login_required +@require_http_methods(["POST"]) +def picture_delete(request, pk): + """Soft-delete a single picture and redirect to album.""" + picture = get_object_or_404( + Picture.objects.filter( + Q(album__gallery__owner=request.user) | Q(album__gallery__shared_with=request.user), + deleted_at__isnull=True + ), + pk=pk + ) + album_id = picture.album_id + picture.soft_delete() + messages.success(request, 'Picture deleted.') + return redirect('gallery:album_detail', pk=album_id) + + +@login_required +@require_http_methods(["POST"]) +def picture_bulk_delete(request, pk): + """Soft-delete selected pictures in an album (bulk operation).""" + album = get_object_or_404( + Album.objects.filter( + Q(gallery__owner=request.user) | Q(gallery__shared_with=request.user), + deleted_at__isnull=True + ), + pk=pk + ) + ids = request.POST.getlist('ids') + if not ids: + messages.warning(request, 'No pictures selected.') + return redirect('gallery:album_detail', pk=pk) + # Restrict to pictures in this album and not already deleted + to_delete = album.pictures.filter( + pk__in=ids, + deleted_at__isnull=True + ) + count = to_delete.count() + for picture in to_delete: + picture.soft_delete() + if count == 1: + messages.success(request, '1 picture deleted.') + else: + messages.success(request, f'{count} pictures deleted.') + return redirect('gallery:album_detail', pk=pk) diff --git a/app/gallery/templates/gallery/album_detail.html b/app/gallery/templates/gallery/album_detail.html new file mode 100644 index 0000000..e45ef37 --- /dev/null +++ b/app/gallery/templates/gallery/album_detail.html @@ -0,0 +1,155 @@ +{% extends "gallery/base.html" %} +{% load static %} + +{% block title %}{{ album.name }} - SmartGallery{% endblock %} + +{% block content %} + +
+
+
+ +

{{ album.name }}

+

{{ album.description }}

+ + +
+ {% for tag in album.tags.all %} + + {{ tag.name }} + + + {% empty %} + No tags + {% endfor %} + +
+ + +
+
+
+ +
+ + Edit + +
+ {% csrf_token %} + +
+
+
+
+ + +
+
+ {% csrf_token %} + +
+

Pictures ({{ pictures|length }})

+
+ + + + + Upload Pictures + +
+
+ + {% if pictures %} + + {% else %} +
+

No pictures yet.

+ + Upload First Picture + +
+ {% endif %} +
+
+{% endblock %} diff --git a/app/gallery/templates/gallery/album_form.html b/app/gallery/templates/gallery/album_form.html new file mode 100644 index 0000000..5da0248 --- /dev/null +++ b/app/gallery/templates/gallery/album_form.html @@ -0,0 +1,67 @@ +{% extends "gallery/base.html" %} +{% load static %} + +{% block title %}{% if album %}Edit{% else %}Create{% endif %} Album - SmartGallery{% endblock %} + +{% block content %} +
+
+

+ {% if album %}Edit Album{% else %}Create New Album{% endif %} +

+ +
+ {% csrf_token %} + +
+ + + {% if form.name.errors %} +

{{ form.name.errors.0 }}

+ {% endif %} +
+ +
+ + +
+ +
+ + +

Enter tags separated by commas

+
+ +
+ + + Cancel + +
+
+
+
+{% endblock %} diff --git a/app/gallery/templates/gallery/base.html b/app/gallery/templates/gallery/base.html new file mode 100644 index 0000000..1928b78 --- /dev/null +++ b/app/gallery/templates/gallery/base.html @@ -0,0 +1,109 @@ + + + + + + {% block title %}SmartGallery{% endblock %} + + + + + + + + + + {% load static %} + + + {% block extra_head %}{% endblock %} + + + + + + + {% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+ {% endif %} + + +
+ {% block content %}{% endblock %} +
+ + +
+
+

+ SmartGallery © {% now "Y" %} +

+
+
+ + {% block extra_js %}{% endblock %} + + diff --git a/app/gallery/templates/gallery/gallery_detail.html b/app/gallery/templates/gallery/gallery_detail.html new file mode 100644 index 0000000..47f1da1 --- /dev/null +++ b/app/gallery/templates/gallery/gallery_detail.html @@ -0,0 +1,156 @@ +{% extends "gallery/base.html" %} +{% load static %} + +{% block title %}{{ gallery.name }} - SmartGallery{% endblock %} + +{% block content %} + +
+
+
+
+

{{ gallery.name }}

+ + +
+

{{ gallery.description }}

+ +
+ + {{ gallery.get_gallery_type_display }} + + Owner: {{ gallery.owner.username }} + {{ albums|length }} albums +
+ + +
+ {% for tag in gallery.tags.all %} + + {{ tag.name }} + + + {% empty %} + No tags + {% endfor %} + +
+ + +
+
+
+ +
+ + Edit + +
+ {% csrf_token %} + +
+ + Back + +
+
+
+ + +
+
+ {% csrf_token %} + +
+

Albums

+
+ + + + + New Album + +
+
+ + {% if albums %} + + {% else %} +
+

No albums yet.

+ + Create First Album + +
+ {% endif %} +
+
+{% endblock %} diff --git a/app/gallery/templates/gallery/gallery_form.html b/app/gallery/templates/gallery/gallery_form.html new file mode 100644 index 0000000..7cf4387 --- /dev/null +++ b/app/gallery/templates/gallery/gallery_form.html @@ -0,0 +1,79 @@ +{% extends "gallery/base.html" %} +{% load static %} + +{% block title %}{% if gallery %}Edit{% else %}Create{% endif %} Gallery - SmartGallery{% endblock %} + +{% block content %} +
+
+

+ {% if gallery %}Edit Gallery{% else %}Create New Gallery{% endif %} +

+ +
+ {% csrf_token %} + +
+ + + {% if form.name.errors %} +

{{ form.name.errors.0 }}

+ {% endif %} +
+ +
+ + +
+ +
+ + +
+ +
+ + +

Enter tags separated by commas

+
+ +
+ + + Cancel + +
+
+
+
+{% endblock %} diff --git a/app/gallery/templates/gallery/gallery_list.html b/app/gallery/templates/gallery/gallery_list.html new file mode 100644 index 0000000..4252df8 --- /dev/null +++ b/app/gallery/templates/gallery/gallery_list.html @@ -0,0 +1,217 @@ +{% extends "gallery/base.html" %} +{% load static %} + +{% block title %}My Galleries - SmartGallery{% endblock %} + +{% block content %} +
+

+ {% if request.GET.shared %} + Shared With Me + {% else %} + My Galleries + {% endif %} +

+ + + New Gallery + +
+ + +
+
+ {% if request.GET.shared %}{% endif %} + + + +
+
+ +{% if search_query %} + +
+

Search results for “{{ search_query }}”

+ + +
+

Galleries ({{ galleries|length }})

+ {% if galleries %} +
+ {% for gallery in galleries %} +
+ {{ gallery.name }} + {% if gallery.description %}

{{ gallery.description|truncatewords:15 }}

{% endif %} +

{{ gallery.album_count }} albums

+
+ {% endfor %} +
+ {% else %} +

No galleries match.

+ {% endif %} +
+ + +
+

Albums ({{ search_albums|length }})

+ {% if search_albums %} +
+ {% for album in search_albums %} +
+ {{ album.name }} +

{{ album.gallery.name }}

+ {% if album.description %}

{{ album.description|truncatewords:10 }}

{% endif %} +
+ {% endfor %} +
+ {% else %} +

No albums match.

+ {% endif %} +
+ + +
+

Pictures ({{ search_pictures|length }})

+ {% if search_pictures %} + + {% else %} +

No pictures match.

+ {% endif %} +
+
+{% else %} + +{% if galleries %} + +{% else %} +
+

No galleries found.

+ + Create Your First Gallery + +
+{% endif %} +{% endif %} +{% endblock %} diff --git a/app/gallery/templates/gallery/partials/empty.html b/app/gallery/templates/gallery/partials/empty.html new file mode 100644 index 0000000..6e05f14 --- /dev/null +++ b/app/gallery/templates/gallery/partials/empty.html @@ -0,0 +1 @@ + diff --git a/app/gallery/templates/gallery/partials/favorite_button.html b/app/gallery/templates/gallery/partials/favorite_button.html new file mode 100644 index 0000000..ab613be --- /dev/null +++ b/app/gallery/templates/gallery/partials/favorite_button.html @@ -0,0 +1,23 @@ +{% if object_type == 'gallery' %} + +{% elif object_type == 'picture' %} + +{% endif %} diff --git a/app/gallery/templates/gallery/partials/tags_list.html b/app/gallery/templates/gallery/partials/tags_list.html new file mode 100644 index 0000000..305df2f --- /dev/null +++ b/app/gallery/templates/gallery/partials/tags_list.html @@ -0,0 +1,59 @@ +{% if object_type == 'gallery' %} + {% for tag in object.tags.all %} + + {{ tag.name }} + + + {% empty %} + No tags + {% endfor %} + +
+ + +
+{% elif object_type == 'album' %} + {% for tag in object.tags.all %} + + {{ tag.name }} + + + {% empty %} + No tags + {% endfor %} + +
+ + +
+{% endif %} diff --git a/app/gallery/templates/gallery/picture_detail.html b/app/gallery/templates/gallery/picture_detail.html new file mode 100644 index 0000000..9668eea --- /dev/null +++ b/app/gallery/templates/gallery/picture_detail.html @@ -0,0 +1,139 @@ +{% extends "gallery/base.html" %} +{% load static %} + +{% block title %}{{ picture.title|default:"Picture" }} - SmartGallery{% endblock %} + +{% block content %} +
+ + + +
+
+ +
+ {% if signed_url %} + {{ picture.title|default:'Picture' }} + {% else %} +
+

No image available

+

File ID: {{ picture.seaweedfs_file_id }}

+
+ {% endif %} +
+ + +
+
+

+ {{ picture.title|default:"Untitled" }} +

+ + +
+ +

{{ picture.description }}

+ + +
+ {% if picture.width and picture.height %} +

Dimensions: {{ picture.width }} × {{ picture.height }} px

+ {% endif %} + {% if picture.file_size %} +

Size: {{ picture.file_size|filesizeformat }}

+ {% endif %} + {% if picture.taken_at %} +

Taken: {{ picture.taken_at|date:"F d, Y H:i" }}

+ {% endif %} +

Uploaded: {{ picture.uploaded_at|date:"F d, Y" }}

+
+ + +
+

Tags

+
+ {% for tag in picture.user_tags %} + + {{ tag.name }} + + {% empty %} + No tags + {% endfor %} +
+
+ + +
+

AI Tags

+
+ {% for tag in picture.ai_tags %} + + {{ tag.name }} + + {% empty %} + No AI tags + {% endfor %} +
+
+ + + {% if picture.ocr_text %} +
+

Extracted Text

+

{{ picture.ocr_text|truncatewords:50 }}

+
+ {% endif %} + + + {% if picture.exif_data %} +
+

EXIF Data

+
+ {% for key, value in picture.exif_data.items %} +

{{ key }}: {{ value }}

+ {% endfor %} +
+
+ {% endif %} + + +
+
+ {% csrf_token %} + +
+ + Edit (add tags) + +
+ {% csrf_token %} + +
+
+

Re-run AI to refresh object tags and OCR text (e.g. after model update).

+
+
+
+
+{% endblock %} diff --git a/app/gallery/templates/gallery/picture_edit.html b/app/gallery/templates/gallery/picture_edit.html new file mode 100644 index 0000000..5660b31 --- /dev/null +++ b/app/gallery/templates/gallery/picture_edit.html @@ -0,0 +1,72 @@ +{% extends "gallery/base.html" %} +{% load static %} + +{% block title %}Edit Picture - {{ picture.title|default:"Picture" }} - SmartGallery{% endblock %} + +{% block content %} +
+
+ +

Edit Picture

+ +
+ {% csrf_token %} + +
+ + + {% if form.title.errors %} +

{{ form.title.errors.0 }}

+ {% endif %} +
+ +
+ + +
+ +
+ + +

Enter tags separated by commas

+ {% if form.tags.errors %} +

{{ form.tags.errors.0 }}

+ {% endif %} +
+ +
+ + + Cancel + +
+
+
+
+{% endblock %} diff --git a/app/gallery/templates/gallery/picture_upload.html b/app/gallery/templates/gallery/picture_upload.html new file mode 100644 index 0000000..aec180a --- /dev/null +++ b/app/gallery/templates/gallery/picture_upload.html @@ -0,0 +1,140 @@ +{% extends "gallery/base.html" %} +{% load static %} + +{% block title %}Upload Pictures - {{ album.name }} - SmartGallery{% endblock %} + +{% block content %} +
+
+ +

Upload Pictures to {{ album.name }}

+ +
+ {% csrf_token %} + +
+ + +

JPG, PNG, GIF, WebP, HEIC. Select multiple files at once.

+
+ +
+ + + {% if form.file.errors %} +

{{ form.file.errors.0 }}

+ {% endif %} +
+ +
+ + +

ZIP or TAR archive containing images (up to 100MB). Images inside will be extracted and added to the album.

+ {% if form.archive.errors %} +

{{ form.archive.errors.0 }}

+ {% endif %} +
+ +

Provide at least one: multiple images, a single image, or an archive.

+ +
+ +

Object detection → ai_tags, extracted text → ocr_text (runs on GPU worker after upload).

+
+ +
+ +

Camera make/model, date taken, GPS → tags and metadata (runs on CPU worker).

+
+ +
+ + + {% if form.title.errors %} +

{{ form.title.errors.0 }}

+ {% endif %} +
+ +
+ + +
+ +
+ + +

Applied to all uploaded pictures

+
+ +
+ + + Cancel + +
+
+
+
+{% endblock %} diff --git a/app/gallery/templatetags/__init__.py b/app/gallery/templatetags/__init__.py new file mode 100644 index 0000000..4e57c57 --- /dev/null +++ b/app/gallery/templatetags/__init__.py @@ -0,0 +1 @@ +# Template tags for gallery app diff --git a/app/gallery/templatetags/gallery_tags.py b/app/gallery/templatetags/gallery_tags.py new file mode 100644 index 0000000..5328a47 --- /dev/null +++ b/app/gallery/templatetags/gallery_tags.py @@ -0,0 +1,19 @@ +""" +Custom template tags for Gallery app +""" +from django import template + +register = template.Library() + + +@register.filter +def filesizeformat(value): + """Format file size in human-readable format""" + if value is None: + return "Unknown" + + for unit in ['B', 'KB', 'MB', 'GB']: + if value < 1024.0: + return f"{value:.1f} {unit}" + value /= 1024.0 + return f"{value:.1f} TB" diff --git a/app/gallery/urls.py b/app/gallery/urls.py new file mode 100644 index 0000000..3edb94f --- /dev/null +++ b/app/gallery/urls.py @@ -0,0 +1,56 @@ +""" +URL configuration for Gallery app +""" +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import GalleryViewSet, AlbumViewSet, PictureViewSet, TagViewSet +from . import template_views + +app_name = 'gallery' + +# API routes +router = DefaultRouter() +router.register(r'galleries', GalleryViewSet, basename='gallery') +router.register(r'albums', AlbumViewSet, basename='album') +router.register(r'pictures', PictureViewSet, basename='picture') +router.register(r'tags', TagViewSet, basename='tag') + +urlpatterns = [ + # API routes + path('api/', include(router.urls)), + + # Template-based routes + path('', template_views.gallery_list, name='gallery_list'), + path('galleries/create/', template_views.gallery_create, name='gallery_create'), + path('galleries//', template_views.gallery_detail, name='gallery_detail'), + path('galleries//delete/', template_views.gallery_delete, name='gallery_delete'), + path('galleries//edit/', template_views.gallery_edit, name='gallery_edit'), + path('galleries/delete/', template_views.gallery_bulk_delete, name='gallery_bulk_delete'), + + # HTMX endpoints for galleries + path('galleries//toggle-favorite/', template_views.gallery_toggle_favorite, name='gallery_toggle_favorite'), + path('galleries//add-tag/', template_views.gallery_add_tag, name='gallery_add_tag'), + path('galleries//remove-tag/', template_views.gallery_remove_tag, name='gallery_remove_tag'), + + # Albums + path('albums//', template_views.album_detail, name='album_detail'), + path('albums//delete/', template_views.album_delete, name='album_delete'), + path('albums//edit/', template_views.album_edit, name='album_edit'), + path('galleries//albums/create/', template_views.album_create, name='album_create'), + path('galleries//albums/delete/', template_views.album_bulk_delete, name='album_bulk_delete'), + + # HTMX endpoints for albums + path('albums//add-tag/', template_views.album_add_tag, name='album_add_tag'), + path('albums//remove-tag/', template_views.album_remove_tag, name='album_remove_tag'), + + # Pictures + path('pictures//', template_views.picture_detail, name='picture_detail'), + path('pictures//edit/', template_views.picture_edit, name='picture_edit'), + path('pictures//delete/', template_views.picture_delete, name='picture_delete'), + path('albums//pictures/upload/', template_views.picture_upload, name='picture_upload'), + path('albums//pictures/delete/', template_views.picture_bulk_delete, name='picture_bulk_delete'), + + # HTMX endpoints for pictures + path('pictures//toggle-favorite/', template_views.picture_toggle_favorite, name='picture_toggle_favorite'), + path('pictures//reprocess-ai/', template_views.picture_reprocess_ai, name='picture_reprocess_ai'), +] diff --git a/app/gallery/utils.py b/app/gallery/utils.py new file mode 100644 index 0000000..4b515a7 --- /dev/null +++ b/app/gallery/utils.py @@ -0,0 +1,229 @@ +""" +Utility functions for Gallery app, including signed URL generation for SeaweedFS +""" +import hmac +import hashlib +import base64 +import io +import time +import uuid +import zipfile +import tarfile +from urllib.parse import urlencode, quote +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.core.files.storage import default_storage +from django.core.files.uploadedfile import SimpleUploadedFile +import logging + +logger = logging.getLogger(__name__) + +def generate_signed_url(file_id, expires_in=3600, secret_key=None, algorithm='md5'): + """ + Generate a signed URL for SeaweedFS file access. + + The signed URL format is compatible with Nginx secure_link module: + /path/to/file?st=signature&e=expires + + Args: + file_id: SeaweedFS file ID + expires_in: URL expiration time in seconds (default: 1 hour) + secret_key: Secret key for signing (defaults to SECRET_KEY from settings) + algorithm: Hash algorithm ('md5' for nginx secure_link, 'sha256' for custom) + + Returns: + dict with 'url' and 'expires_at' keys + """ + if secret_key is None: + secret_key = getattr(settings, 'GALLERY_SIGNED_URL_SECRET', None) + if secret_key is None: + # Fallback to Django SECRET_KEY + secret_key = settings.SECRET_KEY + + if not secret_key: + raise ImproperlyConfigured( + "GALLERY_SIGNED_URL_SECRET or SECRET_KEY must be set for signed URLs" + ) + + # Calculate expiration timestamp + expires_at = int(time.time()) + expires_in + + # Get base URL and construct full URI path + base_url = getattr(settings, 'GALLERY_MEDIA_BASE_URL', '/media') + uri_path = f"{base_url}/{quote(file_id)}" + + # Create the string to sign + # For nginx secure_link_md5: "$uri$secure_link_expires$secure_link_secret" + # This means: /media/file_id + expires_at + secret_key + string_to_sign = f"{uri_path}{expires_at}{secret_key}" + # Generate signature based on algorithm + if algorithm == 'md5': + # Use MD5 for nginx secure_link compatibility + signature = hashlib.md5(string_to_sign.encode('utf-8')).digest() + else: + # Use HMAC-SHA256 for custom validation + signature = hmac.new( + secret_key.encode('utf-8'), + string_to_sign.encode('utf-8'), + hashlib.sha256 + ).digest() + signature = base64.urlsafe_b64encode(signature).decode('utf-8').rstrip('=') + + # Build query parameters + params = { + 'st': signature, # signature + 'e': expires_at # expires + } + + # Construct the signed URL + signed_url = f"{uri_path}?{urlencode(params)}" + # logger.info(f"{string_to_sign=} {len(string_to_sign)=} {repr(string_to_sign)} | {signature=} {len(signature)=} {repr(signature)} | {uri_path=} | {expires_at=} | {signed_url=}") + + return { + 'url': signed_url, + 'expires_at': expires_at, + 'expires_in': expires_in + } + + +def verify_signed_url(file_id, signature, expires_at, secret_key=None, algorithm='md5', uri_path=None): + """ + Verify a signed URL signature. + + This can be used server-side for additional validation (Nginx handles it by default). + + Args: + file_id: SeaweedFS file ID + signature: Signature from URL (st parameter) + expires_at: Expiration timestamp from URL (e parameter) + secret_key: Secret key for verification + algorithm: Hash algorithm ('md5' or 'sha256') + uri_path: Full URI path (for MD5 verification) + + Returns: + bool: True if signature is valid and not expired + """ + if secret_key is None: + secret_key = getattr(settings, 'GALLERY_SIGNED_URL_SECRET', None) + if secret_key is None: + secret_key = settings.SECRET_KEY + + if not secret_key: + return False + + # Check if expired + if int(time.time()) > int(expires_at): + return False + + # Recreate the signature + if algorithm == 'md5': + if uri_path is None: + base_url = getattr(settings, 'GALLERY_MEDIA_BASE_URL', '/media') + uri_path = f"{base_url}/{quote(file_id)}" + string_to_sign = f"{uri_path}{expires_at}{secret_key}" + expected_signature = hashlib.md5(string_to_sign.encode('utf-8')).digest() + expected_signature_b64 = base64.urlsafe_b64encode(expected_signature).rstrip(b'=') + return hmac.compare_digest(expected_signature_b64, signature) + else: + # SHA256 verification + base_url = getattr(settings, 'GALLERY_MEDIA_BASE_URL', '/media') + uri_path = uri_path or f"{base_url}/{quote(file_id)}" + string_to_sign = f"{uri_path}{expires_at}{secret_key}" + expected_signature = hmac.new( + secret_key.encode('utf-8'), + string_to_sign.encode('utf-8'), + hashlib.sha256 + ).digest() + expected_signature_b64 = base64.urlsafe_b64encode(expected_signature).decode('utf-8').rstrip('=') + signature_padded = signature + if len(signature_padded) % 4: + signature_padded += '=' * (4 - len(signature_padded) % 4) + return hmac.compare_digest(expected_signature_b64, signature_padded) + + +def upload_picture_file(file, album_id, content_type=None): + """ + Save an uploaded picture file to storage (S3/SeaweedFS or local MEDIA_ROOT). + Returns the storage path to use as seaweedfs_file_id for the Picture model. + + Args: + file: Uploaded file object (Django UploadedFile) + album_id: Album id (used in path) + content_type: Optional MIME type (defaults to file.content_type) + + Returns: + str: Storage path/key to store in Picture.seaweedfs_file_id + """ + ext = (file.name or '').split('.')[-1].lower() or 'jpg' + if ext not in ('jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'): + ext = 'jpg' + name = f'pictures/{album_id}/{uuid.uuid4().hex}.{ext}' + path = default_storage.save(name, file) + return path + + +IMAGE_EXTENSIONS = frozenset(('jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif')) + + +def _is_image_filename(name): + if not name or '/' in name or '\\' in name: + return False + ext = name.rsplit('.', 1)[-1].lower() if '.' in name else '' + return ext in IMAGE_EXTENSIONS + + +def extract_images_from_archive(archive_file, max_size=100 * 1024 * 1024): + """ + Extract image files from a ZIP or TAR archive. + Yields (filename, file_like_object) for each image. + Skips non-image entries. Raises ValueError for invalid or too-large archives. + + Args: + archive_file: Django UploadedFile (ZIP or TAR/TAR.GZ) + max_size: Max total uncompressed size to process (default 100MB) + + Yields: + tuple: (original_filename, file-like object with .read(), .name, .size) + """ + name = (getattr(archive_file, 'name', '') or '').lower() + archive_file.seek(0) + total_size = 0 + + if name.endswith('.zip'): + with zipfile.ZipFile(archive_file, 'r') as zf: + for info in zf.infolist(): + if info.is_dir(): + continue + if not _is_image_filename(info.filename): + continue + total_size += info.file_size + if total_size > max_size: + raise ValueError(f'Archive exceeds maximum size ({max_size // (1024*1024)}MB)') + data = zf.read(info.filename) + yield (info.filename, SimpleUploadedFile( + name=info.filename.rsplit('/')[-1], + content=data, + content_type='application/octet-stream', + )) + elif name.endswith('.tar') or name.endswith('.tar.gz') or name.endswith('.tgz'): + mode = 'r:gz' if ('.gz' in name or name.endswith('.tgz')) else 'r' + with tarfile.open(fileobj=archive_file, mode=mode) as tf: + for member in tf.getmembers(): + if not member.isfile(): + continue + if not _is_image_filename(member.name): + continue + total_size += member.size + if total_size > max_size: + raise ValueError(f'Archive exceeds maximum size ({max_size // (1024*1024)}MB)') + f = tf.extractfile(member) + if f is None: + continue + data = f.read() + yield (member.name, SimpleUploadedFile( + name=member.name.rsplit('/')[-1], + content=data, + content_type='application/octet-stream', + )) + else: + raise ValueError('Unsupported archive format. Use .zip, .tar, or .tar.gz') diff --git a/app/gallery/views.py b/app/gallery/views.py new file mode 100644 index 0000000..96cab3e --- /dev/null +++ b/app/gallery/views.py @@ -0,0 +1,282 @@ +""" +API views for Gallery app +""" +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from django.shortcuts import get_object_or_404 +from django.db.models import Q +from .models import Gallery, Album, Picture, GalleryShare, Tag +from .serializers import ( + GallerySerializer, GalleryDetailSerializer, + AlbumSerializer, AlbumDetailSerializer, + PictureSerializer, TagSerializer +) +from .utils import generate_signed_url + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + """ + ViewSet for Tag operations (read-only, tags are managed through objects) + """ + serializer_class = TagSerializer + permission_classes = [IsAuthenticated] + search_fields = ['name', 'slug'] + + def get_queryset(self): + """Get tags used by the user's galleries/albums/pictures""" + user = self.request.user + # Get tags from user's galleries, albums, and pictures + gallery_tags = Tag.objects.filter(galleries__owner=user).distinct() + album_tags = Tag.objects.filter(albums__gallery__owner=user).distinct() + picture_tags = Tag.objects.filter(pictures__album__gallery__owner=user).distinct() + + return (gallery_tags | album_tags | picture_tags).distinct().order_by('name') + + @action(detail=False, methods=['get']) + def popular(self, request): + """Get most popular tags""" + limit = int(request.query_params.get('limit', 20)) + tags = Tag.objects.filter(usage_count__gt=0).order_by('-usage_count')[:limit] + serializer = self.get_serializer(tags, many=True) + return Response(serializer.data) + + +class GalleryViewSet(viewsets.ModelViewSet): + """ + ViewSet for Gallery operations + """ + serializer_class = GallerySerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """Get galleries accessible by the user""" + user = self.request.user + queryset = Gallery.objects.filter( + Q(owner=user) | Q(shared_with=user), + deleted_at__isnull=True + ).distinct() + + # Filter by tags if provided + tags = self.request.query_params.getlist('tags') + if tags: + queryset = queryset.filter(tags__name__in=tags).distinct() + + return queryset + + def get_serializer_class(self): + """Use detail serializer for retrieve action""" + if self.action == 'retrieve': + return GalleryDetailSerializer + return GallerySerializer + + def perform_create(self, serializer): + """Set owner to current user""" + serializer.save(owner=self.request.user) + + @action(detail=True, methods=['post']) + def share(self, request, pk=None): + """Share gallery with users by email""" + gallery = get_object_or_404(Gallery, pk=pk, deleted_at__isnull=True) + + # Check permission - only owner can share + if gallery.owner != request.user: + return Response( + {'error': 'Only gallery owner can share'}, + status=status.HTTP_403_FORBIDDEN + ) + + emails = request.data.get('emails', []) + can_edit = request.data.get('can_edit', False) + + if not isinstance(emails, list): + return Response( + {'error': 'emails must be a list'}, + status=status.HTTP_400_BAD_REQUEST + ) + + shared_users = [] + from django.contrib.auth import get_user_model + User = get_user_model() + + for email in emails: + try: + user = User.objects.get(email=email) + share, created = GalleryShare.objects.get_or_create( + gallery=gallery, + user=user, + defaults={'can_edit': can_edit} + ) + if not created: + share.can_edit = can_edit + share.save() + shared_users.append(user.email) + except User.DoesNotExist: + # Could send invitation email here + pass + + return Response({ + 'message': f'Gallery shared with {len(shared_users)} users', + 'shared_with': shared_users + }) + + @action(detail=True, methods=['post']) + def unshare(self, request, pk=None): + """Remove share access for a user""" + gallery = self.get_object() + + if gallery.owner != request.user: + return Response( + {'error': 'Only gallery owner can unshare'}, + status=status.HTTP_403_FORBIDDEN + ) + + user_id = request.data.get('user_id') + if not user_id: + return Response( + {'error': 'user_id is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + GalleryShare.objects.filter(gallery=gallery, user_id=user_id).delete() + + return Response({'message': 'Share access removed'}) + + @action(detail=True, methods=['post']) + def toggle_favorite(self, request, pk=None): + """Toggle favorite status""" + gallery = self.get_object() + gallery.is_favorite = not gallery.is_favorite + gallery.save(update_fields=['is_favorite']) + return Response({'is_favorite': gallery.is_favorite}) + + +class AlbumViewSet(viewsets.ModelViewSet): + """ + ViewSet for Album operations + """ + serializer_class = AlbumSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """Get albums accessible by the user""" + user = self.request.user + gallery_id = self.request.query_params.get('gallery_id') + + queryset = Album.objects.filter( + gallery__owner=user, + deleted_at__isnull=True + ) | Album.objects.filter( + gallery__shared_with=user, + deleted_at__isnull=True + ) + + if gallery_id: + queryset = queryset.filter(gallery_id=gallery_id) + + # Filter by tags if provided + tags = self.request.query_params.getlist('tags') + if tags: + queryset = queryset.filter(tags__name__in=tags).distinct() + + return queryset.distinct() + + def get_serializer_class(self): + """Use detail serializer for retrieve action""" + if self.action == 'retrieve': + return AlbumDetailSerializer + return AlbumSerializer + + def perform_create(self, serializer): + """Check permission and create album""" + gallery = serializer.validated_data['gallery'] + + # Check if user has access to gallery + if gallery.owner != self.request.user and not gallery.shared_with.filter(id=self.request.user.id).exists(): + raise PermissionError('You do not have access to this gallery') + + serializer.save() + + +class PictureViewSet(viewsets.ModelViewSet): + """ + ViewSet for Picture operations + """ + serializer_class = PictureSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """Get pictures accessible by the user""" + user = self.request.user + album_id = self.request.query_params.get('album_id') + + queryset = Picture.objects.filter( + album__gallery__owner=user, + deleted_at__isnull=True + ) | Picture.objects.filter( + album__gallery__shared_with=user, + deleted_at__isnull=True + ) + + if album_id: + queryset = queryset.filter(album_id=album_id) + + # Filter by tags if provided + tags = self.request.query_params.getlist('tags') + if tags: + queryset = queryset.filter(tags__name__in=tags).distinct() + + return queryset.distinct() + + @action(detail=True, methods=['get']) + def signed_url(self, request, pk=None): + """Get a new signed URL for the picture""" + picture = self.get_object() + + expires_in = int(request.query_params.get('expires_in', 3600)) + signed = generate_signed_url(picture.seaweedfs_file_id, expires_in=expires_in) + + return Response(signed) + + @action(detail=True, methods=['post']) + def toggle_favorite(self, request, pk=None): + """Toggle favorite status""" + picture = self.get_object() + picture.is_favorite = not picture.is_favorite + picture.save(update_fields=['is_favorite']) + return Response({'is_favorite': picture.is_favorite}) + + @action(detail=True, methods=['post']) + def add_tag(self, request, pk=None): + """Add a tag to the picture""" + picture = self.get_object() + tag_name = request.data.get('tag') + + if not tag_name: + return Response( + {'error': 'tag is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + picture.add_tag(tag_name) + tag_names = [tag.name for tag in picture.tags.all()] + + return Response({'tags': tag_names}) + + @action(detail=True, methods=['post']) + def remove_tag(self, request, pk=None): + """Remove a tag from the picture""" + picture = self.get_object() + tag_name = request.data.get('tag') + + if not tag_name: + return Response( + {'error': 'tag is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + picture.remove_tag(tag_name) + tag_names = [tag.name for tag in picture.tags.all()] + + return Response({'tags': tag_names}) diff --git a/app/manage.py b/app/manage.py new file mode 100755 index 0000000..8e7ac79 --- /dev/null +++ b/app/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/app/templates/account/base.html b/app/templates/account/base.html new file mode 100644 index 0000000..0f452f3 --- /dev/null +++ b/app/templates/account/base.html @@ -0,0 +1,53 @@ + + + + + + {% block head_title %}{% endblock %} - SmartGallery + + + + + + {% load static %} + + + {% block extra_head %}{% endblock %} + + +
+
+ +
+ +

📷 SmartGallery

+
+

{% block page_title %}Welcome{% endblock %}

+
+ + + {% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+ {% endif %} + + +
+ {% block content %}{% endblock %} +
+ + +
+ {% block footer_links %}{% endblock %} +
+
+
+ + {% block extra_js %}{% endblock %} + + diff --git a/app/templates/account/login.html b/app/templates/account/login.html new file mode 100644 index 0000000..2417091 --- /dev/null +++ b/app/templates/account/login.html @@ -0,0 +1,155 @@ +{% extends "account/base.html" %} +{% load i18n %} +{% load account socialaccount %} + +{% block head_title %}{% trans "Sign In" %}{% endblock %} +{% block page_title %}Sign in to your account{% endblock %} + +{% block content %} +
+

Sign In

+ + + {% get_providers as socialaccount_providers %} + {% if socialaccount_providers %} +
+
+
+
+
+
+ Or continue with +
+
+ +
+ {% for provider in socialaccount_providers %} + {% if provider.id == "google" %} + + + + + + + + + {% elif provider.id == "facebook" %} + + + + + + {% elif provider.id == "github" %} + + + + + + {% else %} + + {{ provider.name }} + + {% endif %} + {% endfor %} +
+ +
+
+
+
+
+
+ Or use email +
+
+
+ {% endif %} +
+ + +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} + +
+ + + {% if form.login.errors %} +

{{ form.login.errors.0 }}

+ {% endif %} +
+ +
+ + + {% if form.password.errors %} +

{{ form.password.errors.0 }}

+ {% endif %} +
+ +
+
+ + +
+ + +
+ + {% if redirect_field_value %} + + {% endif %} + +
+ +
+
+
+{% endblock %} + +{% block footer_links %} +

+ Don't have an account? + + Sign up + +

+{% endblock %} diff --git a/app/templates/account/logout.html b/app/templates/account/logout.html new file mode 100644 index 0000000..af7df61 --- /dev/null +++ b/app/templates/account/logout.html @@ -0,0 +1,34 @@ +{% extends "account/base.html" %} +{% load i18n %} + +{% block head_title %}{% trans "Sign Out" %}{% endblock %} +{% block page_title %}Sign out{% endblock %} + +{% block content %} +
+

Sign Out

+ +

+ Are you sure you want to sign out? +

+ +
+ {% csrf_token %} + {% if redirect_field_value %} + + {% endif %} + +
+ + + + Cancel + +
+
+
+{% endblock %} diff --git a/app/templates/account/password_reset.html b/app/templates/account/password_reset.html new file mode 100644 index 0000000..0fb3d4b --- /dev/null +++ b/app/templates/account/password_reset.html @@ -0,0 +1,58 @@ +{% extends "account/base.html" %} +{% load i18n %} + +{% block head_title %}{% trans "Password Reset" %}{% endblock %} +{% block page_title %}Reset your password{% endblock %} + +{% block content %} +
+

Password Reset

+ +

+ Forgotten your password? Enter your email address below, and we'll send you an email allowing you to reset it. +

+ +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} + +
+ + + {% if form.email.errors %} +

{{ form.email.errors.0 }}

+ {% endif %} +
+ +
+ +
+
+
+{% endblock %} + +{% block footer_links %} +

+ Remember your password? + + Sign in + +

+{% endblock %} diff --git a/app/templates/account/password_reset_done.html b/app/templates/account/password_reset_done.html new file mode 100644 index 0000000..d971358 --- /dev/null +++ b/app/templates/account/password_reset_done.html @@ -0,0 +1,26 @@ +{% extends "account/base.html" %} +{% load i18n %} + +{% block head_title %}{% trans "Password Reset" %}{% endblock %} +{% block page_title %}Password reset email sent{% endblock %} + +{% block content %} +
+
+ + + +
+ +

Check your email

+ +

+ We have sent you an email. If you have not received it please check your spam folder. Otherwise contact us if you do not receive it in a few minutes. +

+ + + Back to sign in + +
+{% endblock %} diff --git a/app/templates/account/signup.html b/app/templates/account/signup.html new file mode 100644 index 0000000..38ae198 --- /dev/null +++ b/app/templates/account/signup.html @@ -0,0 +1,172 @@ +{% extends "account/base.html" %} +{% load i18n %} + +{% block head_title %}{% trans "Sign Up" %}{% endblock %} +{% block page_title %}Create your account{% endblock %} + +{% block content %} +
+

Sign Up

+ + + {% load socialaccount %} + {% get_providers as socialaccount_providers %} + {% if socialaccount_providers %} +
+
+
+
+
+
+ Or continue with +
+
+ +
+ {% for provider in socialaccount_providers %} + {% if provider.id == "google" %} + + + + + + + + + {% elif provider.id == "facebook" %} + + + + + + {% elif provider.id == "github" %} + + + + + + {% else %} + + {{ provider.name }} + + {% endif %} + {% endfor %} +
+ +
+
+
+
+
+
+ Or use email +
+
+
+ {% endif %} +
+ + +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} + +
+ + + {% if form.email.errors %} +

{{ form.email.errors.0 }}

+ {% endif %} +
+ + {% if form.email2 %} +
+ + + {% if form.email2.errors %} +

{{ form.email2.errors.0 }}

+ {% endif %} +
+ {% endif %} + +
+ + + {% if form.password1.errors %} +

{{ form.password1.errors.0 }}

+ {% endif %} +
+ +
+ + + {% if form.password2.errors %} +

{{ form.password2.errors.0 }}

+ {% endif %} +
+ + {% if redirect_field_value %} + + {% endif %} + +
+ +
+
+
+{% endblock %} + +{% block footer_links %} +

+ Already have an account? + + Sign in + +

+{% endblock %} diff --git a/app/tests/__init__.py b/app/tests/__init__.py new file mode 100644 index 0000000..7433404 --- /dev/null +++ b/app/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Tests package for the gallery application. +""" diff --git a/app/tests/conftest.py b/app/tests/conftest.py new file mode 100644 index 0000000..14c6b2e --- /dev/null +++ b/app/tests/conftest.py @@ -0,0 +1,125 @@ +""" +Pytest configuration and shared fixtures for Django tests. +""" +import pytest +from django.contrib.auth import get_user_model +from django.test import RequestFactory + +User = get_user_model() + + +@pytest.fixture(scope="function") +def db_access(db): + """ + Fixture to ensure database access is available. + This is a wrapper around pytest-django's db fixture. + """ + return db + + +@pytest.fixture +def user(db): + """ + Create a test user. + """ + return User.objects.create_user( + username="testuser", + email="test@example.com", + password="testpass123", + ) + + +@pytest.fixture +def admin_user(db): + """ + Create an admin user. + """ + return User.objects.create_superuser( + username="testuser", + email="admin@example.com", + password="adminpass123", + ) + + +@pytest.fixture +def authenticated_client(client, user): + """ + Create an authenticated client with a logged-in user. + Use login() instead of force_login() to avoid session UpdateError. + """ + # Use login() with password to avoid session UpdateError + # This properly initializes the session through the authentication backend + # client.login(username=user.username, password='testpass123') + client.force_login(user) + return client + + +@pytest.fixture +def request_factory(): + """ + Create a request factory for testing views. + """ + return RequestFactory() + + +@pytest.fixture +def api_client(): + """ + Create a DRF API client for testing API views. + """ + from rest_framework.test import APIClient + return APIClient() + + +@pytest.fixture +def authenticated_api_client(api_client, user): + """ + Create an authenticated DRF API client. + """ + api_client.force_authenticate(user=user) + return api_client + + +@pytest.fixture(autouse=True) +def enable_db_access_for_all_tests(db): + """ + Automatically enable database access for all tests. + This ensures all tests can access the database without needing @pytest.mark.django_db. + """ + pass + + +@pytest.fixture +def settings_override(settings): + """ + Helper fixture to override Django settings in tests. + + Usage: + def test_something(settings_override): + settings_override.DEBUG = True + # ... test code ... + """ + return settings + + +@pytest.fixture +def celery_config(): + """ + Configure Celery for testing (use in-memory broker). + """ + return { + 'task_always_eager': True, # Execute tasks synchronously + 'task_eager_propagates': True, + 'broker_url': 'memory://', + 'result_backend': 'cache+memory://', + } + + +@pytest.fixture +def celery_worker_parameters(): + """ + Parameters for Celery worker in tests. + """ + return { + 'perform_ping_check': False, + } diff --git a/app/tests/e2e/README.md b/app/tests/e2e/README.md new file mode 100644 index 0000000..57f020c --- /dev/null +++ b/app/tests/e2e/README.md @@ -0,0 +1,45 @@ +# E2E tests (Playwright + pytest-django) + +End-to-end tests run in a real browser against the Django live server. + +## Setup + +1. Install dev dependencies (includes `pytest-playwright`): + + ```bash + uv sync --extra dev + # or: pip install -e ".[dev]" + ``` + +2. Install Playwright browsers (once): + + ```bash + playwright install chromium + ``` + +## Run + +- All e2e tests: + ```bash + pytest app/tests/e2e -m e2e -v + ``` + +- Single test: + ```bash + pytest app/tests/e2e/test_login_and_create_gallery.py -m e2e -v + ``` + +- Skip e2e in normal runs: + ```bash + pytest -m "not e2e" + ``` + +## Test: login and create gallery + +`test_login_and_create_gallery`: + +1. Opens `/accounts/login/`, fills email and password, submits. +2. Goes to `/galleries/create/`, fills name and description, submits. +3. Asserts redirect to gallery list and the new gallery name is visible. + +Uses a fixture-created user (`e2e@example.com` / `e2epass123`) and `live_server` from pytest-django. diff --git a/app/tests/e2e/__init__.py b/app/tests/e2e/__init__.py new file mode 100644 index 0000000..02a232f --- /dev/null +++ b/app/tests/e2e/__init__.py @@ -0,0 +1 @@ +# E2E tests (Playwright + pytest-django live server) diff --git a/app/tests/e2e/conftest.py b/app/tests/e2e/conftest.py new file mode 100644 index 0000000..f21f185 --- /dev/null +++ b/app/tests/e2e/conftest.py @@ -0,0 +1,27 @@ +""" +Pytest fixtures for E2E tests (Playwright + Django live server). + +Requires: pytest-playwright, pytest-django. Run with: + pytest app/tests/e2e -m e2e + playwright install chromium # first-time browser install +""" +import pytest +from django.contrib.auth import get_user_model +import os + +User = get_user_model() +os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" +@pytest.fixture +def e2e_user(db): + """Create a user for E2E login. Email login is used (allauth).""" + return User.objects.create_user( + username="e2etest", + email="e2e@example.com", + password="e2epass123", + ) + + +@pytest.fixture +def e2e_login_credentials(e2e_user): + """Return (email, password) for E2E login.""" + return ("e2e@example.com", "e2epass123") diff --git a/app/tests/e2e/test_login_and_create_gallery.py b/app/tests/e2e/test_login_and_create_gallery.py new file mode 100644 index 0000000..0c9e87d --- /dev/null +++ b/app/tests/e2e/test_login_and_create_gallery.py @@ -0,0 +1,53 @@ +""" +E2E test: login and create gallery (Playwright + pytest-django live server). + +Run: + pytest app/tests/e2e/test_login_and_create_gallery.py -m e2e -v + playwright install chromium # once, to install browser +""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.e2e +@pytest.mark.django_db +def test_login_and_create_gallery( + page: Page, + live_server, + e2e_login_credentials, +): + """ + Log in via the login page, go to create gallery, submit form, + and assert the new gallery appears on the gallery list. + """ + base_url = live_server.url + email, password = e2e_login_credentials + + # --- Login --- + page.goto(f"{base_url}/accounts/login/") + expect(page.get_by_role("heading", name="Sign In")).to_be_visible() + page.get_by_label("Email address").fill(email) + page.get_by_label("Password").fill(password) + page.get_by_role("button", name="Sign in").click() + + # After login we should land on gallery list (or redirect to next) + page.wait_for_url(f"{base_url}/**", wait_until="networkidle") + expect(page).not_to_have_url(f"{base_url}/accounts/login/") + + # --- Create gallery page --- + page.goto(f"{base_url}/galleries/create/") + expect(page.get_by_role("heading", name="Create New Gallery")).to_be_visible() + + # --- Fill and submit create gallery form --- + gallery_name = "E2E Test Gallery" + gallery_description = "Created by Playwright e2e test" + page.get_by_label("Gallery Name *").fill(gallery_name) + page.get_by_label("Description").fill(gallery_description) + page.get_by_label("Gallery Type").select_option("private") + page.get_by_role("button", name="Create Gallery").click() + + # --- Assert redirect to list and gallery visible --- + page.wait_for_url(f"{base_url}/**", wait_until="networkidle") + expect(page).to_have_url(f"{base_url}/galleries/1/") + expect(page.get_by_role("link", name="My Galleries")).to_be_visible() + expect(page.get_by_role("heading", name=gallery_name)).to_be_visible() diff --git a/app/tests/gallery/__init__.py b/app/tests/gallery/__init__.py new file mode 100644 index 0000000..f3726ff --- /dev/null +++ b/app/tests/gallery/__init__.py @@ -0,0 +1,3 @@ +""" +Tests for the gallery app. +""" diff --git a/app/tests/gallery/test_models.py b/app/tests/gallery/test_models.py new file mode 100644 index 0000000..97ab99e --- /dev/null +++ b/app/tests/gallery/test_models.py @@ -0,0 +1,129 @@ +""" +Tests for gallery models. +""" +import pytest +from django.contrib.auth import get_user_model +from gallery.models import Gallery, Album, Picture, Tag + +User = get_user_model() + + +@pytest.mark.django_db +class TestGalleryModel: + """Test Gallery model.""" + + def test_create_gallery(self, user): + """Test creating a gallery.""" + gallery = Gallery.objects.create( + owner=user, + name="Test Gallery", + description="Test description", + gallery_type="private" + ) + # print(gallery) + assert gallery.name == "Test Gallery" + assert gallery.owner == user + assert gallery.gallery_type == "private" + assert not gallery.is_favorite + + def test_gallery_soft_delete(self, user): + """Test soft delete functionality.""" + gallery = Gallery.objects.create( + owner=user, + name="Test Gallery" + ) + assert gallery.deleted_at is None + assert not gallery.is_deleted + + gallery.soft_delete() + gallery.refresh_from_db() + assert gallery.deleted_at is not None + assert gallery.is_deleted + + # Gallery still exists in database (soft delete doesn't remove it) + assert Gallery.objects.filter(pk=gallery.pk).exists() + + # But should not appear in filtered queryset (non-deleted only) + assert Gallery.objects.filter(pk=gallery.pk, deleted_at__isnull=True).count() == 0 + + def test_gallery_tags(self, user): + """Test adding and removing tags.""" + gallery = Gallery.objects.create( + owner=user, + name="Test Gallery" + ) + + tag1, _ = Tag.get_or_create_tag("vacation") + tag2, _ = Tag.get_or_create_tag("beach") + + # Tags start with usage_count = 0 + assert tag1.usage_count == 0 + assert tag2.usage_count == 0 + + gallery.add_tag("vacation") + gallery.add_tag("beach") + + # Refresh tags to get updated usage_count + tag1.refresh_from_db() + tag2.refresh_from_db() + + tags = gallery.tags.all() + + assert gallery.tags.count() == 2 + assert tag1 in tags + assert tag2 in tags + # usage_count should be incremented when tags are added + assert tag1.usage_count == 1 + assert tag2.usage_count == 1 + + gallery.remove_tag("vacation") + tag1.refresh_from_db() + assert gallery.tags.count() == 1 + assert tag1 not in gallery.tags.all() + # usage_count should be decremented when tag is removed + assert tag1.usage_count == 0 + + +@pytest.mark.django_db +class TestTagModel: + """Test Tag model.""" + + def test_create_tag(self): + """Test creating a tag.""" + tag, created = Tag.get_or_create_tag("test-tag") + assert tag.name == "test-tag" + assert tag.slug == "test-tag" + # usage_count should be 0 when tag is first created (not used yet) + assert tag.usage_count == 0 + + def test_tag_normalization(self): + """Test tag name normalization.""" + tag1, _ = Tag.get_or_create_tag("Test Tag") + tag2, _ = Tag.get_or_create_tag("test tag") + tag3, _ = Tag.get_or_create_tag("TEST TAG") + + # All should be the same tag + assert tag1 == tag2 == tag3 + assert tag1.name == "test tag" + + def test_tag_usage_count(self): + """Test tag usage count tracking.""" + tag, _ = Tag.get_or_create_tag("test") + # Initially 0 when tag is created but not used + assert tag.usage_count == 0 + + tag.increment_usage() + tag.refresh_from_db() + assert tag.usage_count == 1 + + tag.increment_usage() + tag.refresh_from_db() + assert tag.usage_count == 2 + + tag.decrement_usage() + tag.refresh_from_db() + assert tag.usage_count == 1 + + tag.decrement_usage() + tag.refresh_from_db() + assert tag.usage_count == 0 diff --git a/app/tests/gallery/test_utils.py b/app/tests/gallery/test_utils.py new file mode 100644 index 0000000..e906c86 --- /dev/null +++ b/app/tests/gallery/test_utils.py @@ -0,0 +1,68 @@ +""" +Tests for gallery utility functions. +""" +import pytest +from urllib.parse import urlparse, parse_qs, unquote +from django.conf import settings +from gallery.utils import generate_signed_url, verify_signed_url + + +@pytest.mark.unit +class TestSignedURL: + """Test signed URL generation and verification.""" + + def test_generate_signed_url(self): + """Test generating a signed URL.""" + file_id = "01637037d6" # SeaweedFS file ID format (alphanumeric) + result = generate_signed_url(file_id) + + assert result is not None + assert isinstance(result, dict) + assert "url" in result + assert "expires_at" in result + assert "expires_in" in result + + signed_url = result["url"] + # Parse URL to extract file_id from path (accounting for URL encoding) + parsed_url = urlparse(signed_url) + path_file_id = unquote(parsed_url.path.split('/')[-1]) + assert path_file_id == file_id + assert "e=" in signed_url + assert "st=" in signed_url + + def test_verify_signed_url(self): + """Test verifying a signed URL.""" + file_id = "01637037d6" # SeaweedFS file ID format (alphanumeric) + result = generate_signed_url(file_id) + + # Parse the URL to extract signature and expires_at + parsed_url = urlparse(result["url"]) + query_params = parse_qs(parsed_url.query) + signature = query_params["st"][0].encode() + expires_at = int(query_params["e"][0]) + + # Verify the signed URL with all necessary arguments + assert verify_signed_url(file_id, signature, expires_at) is True + + def test_expired_signed_url(self, settings_override): + """Test that expired URLs are rejected.""" + # Set a very short expiration time + settings_override.GALLERY_SIGNED_URL_EXPIRES_IN = 1 + + file_id = "01637037d6" # SeaweedFS file ID format (alphanumeric) + result = generate_signed_url(file_id, expires_in=1) + + # Parse the URL to extract signature and expires_at + parsed_url = urlparse(result["url"]) + query_params = parse_qs(parsed_url.query) + signature = query_params["st"][0] + expires_at = int(query_params["e"][0]) + + import time + time.sleep(2) # Wait for expiration + + # Verify that expired URL is rejected + assert verify_signed_url(file_id, signature, expires_at) is False + + # Note: This test might be flaky, but demonstrates the concept + # In practice, you'd mock time or use a test-specific expiration diff --git a/app/tests/gallery/test_views.py b/app/tests/gallery/test_views.py new file mode 100644 index 0000000..0a475b1 --- /dev/null +++ b/app/tests/gallery/test_views.py @@ -0,0 +1,48 @@ +""" +Tests for gallery views. +""" +import pytest +from django.urls import reverse +from rest_framework import status + + +@pytest.mark.django_db +class TestGalleryListView: + """Test gallery list view.""" + + def test_list_galleries_unauthenticated(self, client): + """Test that unauthenticated users are redirected to login.""" + url = reverse('gallery:gallery_list') + response = client.get(url) + assert response.status_code in [302, 401] # Redirect or unauthorized + + def test_list_galleries_authenticated(self, authenticated_client, user): + """Test listing galleries for authenticated user.""" + url = reverse('gallery:gallery_list') + response = authenticated_client.get(url) + assert response.status_code == 200 + + +@pytest.mark.django_db +class TestGalleryAPIViewSet: + """Test Gallery API ViewSet.""" + + def test_list_galleries_api(self, authenticated_api_client, user): + """Test listing galleries via API.""" + url = reverse('gallery:gallery-list') + response = authenticated_api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert isinstance(response.data["results"], list) + + def test_create_gallery_api(self, authenticated_api_client, user): + """Test creating a gallery via API.""" + url = reverse('gallery:gallery-list') + data = { + 'name': 'Test Gallery', + 'description': 'Test description', + 'gallery_type': 'private' + } + response = authenticated_api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_201_CREATED + assert response.data['name'] == 'Test Gallery' + assert response.data['owner'] == user.id diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..37de063 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,89 @@ +# Gallery Service Configuration Example +# Copy this file to config.yaml and customize for your environment +# This file is mounted into containers via docker-compose configs + +django: + secret_key: "" # Override via SECRET_KEY env var for security + debug: false + allowed_hosts: + - localhost + - 127.0.0.1 + site_id: 1 + timezone: "UTC" + language_code: "en-us" + +database: + name: "gallery" + user: "postgres" + password: "postgres" # Override via DB_PASSWORD env var + host: "db" + port: 5432 + +authentication: + account_email_verification: "mandatory" # mandatory, optional, none + login_redirect_url: "/" + logout_redirect_url: "/" + +social_auth: + google: + enabled: false + client_id: "" + secret: "" + facebook: + enabled: false + app_id: "" + app_secret: "" + github: + enabled: false + client_id: "" + secret: "" + +celery: + broker_url: "redis://redis:6379/0" + result_backend: "redis://redis:6379/0" + task_time_limit: 1800 # 30 minutes in seconds + task_soft_time_limit: 1500 # 25 minutes in seconds + worker_prefetch_multiplier: 1 + worker_max_tasks_per_child: 1000 + worker_concurrency: 4 + +flower: + port: 5555 + username: "admin" + password: "" # Override via FLOWER_PASSWORD env var + +storage: + use_s3: false + s3: + endpoint_url: "http://seaweedfs:8333" + bucket_name: "gallery" + region_name: "us-east-1" + use_ssl: false + access_key_id: "" + secret_access_key: "" + +static_files: + static_url: "/static/" + static_root: "staticfiles" + media_url: "/media/" + media_root: "media" + +server: + gunicorn_workers: 4 + gunicorn_timeout: 120 + +logging: + # Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL + level: "INFO" + # Optional: Log file path (enables file logging with rotation) + # file: "/var/log/gallery/app.log" + file_max_bytes: 10485760 # 10MB + file_backup_count: 5 + # Per-logger levels (optional, defaults to 'level' above) + django_level: "INFO" + django_request_level: "WARNING" + django_server_level: "INFO" + django_db_level: "WARNING" + celery_level: "INFO" + boto3_level: "WARNING" + botocore_level: "WARNING" diff --git a/docker-compose.gpu.yml b/docker-compose.gpu.yml new file mode 100644 index 0000000..6197719 --- /dev/null +++ b/docker-compose.gpu.yml @@ -0,0 +1,47 @@ +# Docker Compose for Local GPU Worker +# Runs ONLY: celery-gpu worker +# Connects to Redis and Database on remote VPS + +version: '3.8' + +services: + celery-gpu: + build: + context: . + dockerfile: Dockerfile.celery + container_name: gallery_celery_gpu + command: celery -A config worker --loglevel=info --concurrency=${CELERY_GPU_WORKER_CONCURRENCY:-2} --queues=gpu + volumes: + # - ./app:/app + - media_volume:/app/media + configs: + - source: gallery_config + target: /config/config.yaml + env_file: + - .env + environment: + - NVIDIA_VISIBLE_DEVICES=all + - NVIDIA_DRIVER_CAPABILITIES=compute,utility + - CONFIG_PATH=/config/config.yaml + # Redis and Database connections are configured via .env file + # Make sure to set CELERY_BROKER_URL, CELERY_RESULT_BACKEND, and database.* in .env + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + networks: + - gallery_network + +volumes: + media_volume: + +configs: + gallery_config: + file: ./config.yaml + +networks: + gallery_network: + driver: bridge diff --git a/docker-compose.vps.yml b/docker-compose.vps.yml new file mode 100644 index 0000000..9262786 --- /dev/null +++ b/docker-compose.vps.yml @@ -0,0 +1,201 @@ +# Docker Compose for VPS (Cloud Server) +# Runs: web, db, redis, celery (CPU), celery-beat, flower, nginx, seaweedfs +# Does NOT run: celery-gpu (runs on local machine) + +# version: '3.8' + +services: + db: + image: postgres:16-alpine + container_name: gallery_db + environment: + POSTGRES_DB: ${DB_NAME:-gallery} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "${DB_PORT:-5432}:5432" + # PostgreSQL accessible via WireGuard VPN (10.0.0.1:5432) + # No external port exposure needed - secured via WireGuard + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - gallery_network + + redis: + image: redis:7-alpine + container_name: gallery_redis + ports: + - "${REDIS_PORT:-6379}:6379" + # Redis accessible via WireGuard VPN (10.0.0.1:6379) + # No external port exposure needed - secured via WireGuard + volumes: + - redis_data:/data + command: redis-server --appendonly yes --bind 0.0.0.0 + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - gallery_network + + seaweedfs: + image: chrislusf/seaweedfs:latest + container_name: gallery_seaweedfs + # command: "server -master.port=9333 -volume.port=8080 -filer.port=8888 -s3.port=8333" + command: "mini -dir=/data" + ports: + - "9333:9333" # Master + - "8080:8080" # Volume + - "8888:8888" # Filer + - "8333:8333" # S3 + - "23646:23646" # AdminUI + volumes: + - seaweedfs_data:/data + networks: + - gallery_network + # healthcheck: + # test: ["CMD-SHELL", "wget --quiet --tries=1 --spider --timeout=5 http://localhost:8080/status || exit 1"] + # interval: 30s + # timeout: 10s + # retries: 5 + # start_period: 10s + + web: + build: . + container_name: gallery_web + # command: sh -c "python manage.py migrate --noinput && gunicorn --bind 0.0.0.0:8000 --workers ${GUNICORN_WORKERS:-4} --timeout ${GUNICORN_TIMEOUT:-120} config.wsgi:application" + command: sh -c "python manage.py migrate --noinput && python3 manage.py runserver 0.0.0.0:8000" + volumes: + - ./app:/app + - static_volume:/app/staticfiles + - media_volume:/app/media + configs: + - source: gallery_config + target: /config/config.yaml + ports: + - "8000:8000" + environment: + - CONFIG_PATH=/config/config.yaml + env_file: + - .env + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + # seaweedfs: + # condition: service_healthy + networks: + - gallery_network + + celery: + build: . + container_name: gallery_celery + command: celery -A config worker --loglevel=info --concurrency=${CELERY_CPU_WORKER_CONCURRENCY:-4} --queues=cpu + volumes: + - ./app:/app + - media_volume:/app/media + configs: + - source: gallery_config + target: /config/config.yaml + environment: + - CONFIG_PATH=/config/config.yaml + env_file: + - .env + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - gallery_network + + celery-beat: + build: . + container_name: gallery_celery_beat + command: celery -A config beat --loglevel=info + volumes: + - ./app:/app + configs: + - source: gallery_config + target: /config/config.yaml + environment: + - CONFIG_PATH=/config/config.yaml + env_file: + - .env + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - gallery_network + + flower: + build: . + container_name: gallery_flower + command: > + sh -c " + if [ -n \"$$FLOWER_USERNAME\" ] && [ -n \"$$FLOWER_PASSWORD\" ]; then + celery -A config flower --port=5555 --basic_auth=$$FLOWER_USERNAME:$$FLOWER_PASSWORD + else + echo 'ERROR: FLOWER_USERNAME and FLOWER_PASSWORD must be set for security!' + exit 1 + fi + " + ports: + - "${FLOWER_PORT:-5555}:5555" + configs: + - source: gallery_config + target: /config/config.yaml + environment: + - CONFIG_PATH=/config/config.yaml + env_file: + - .env + depends_on: + - celery + - redis + networks: + - gallery_network + + nginx: + image: nginx:alpine + container_name: gallery_nginx + # env_file: + # - .env + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + - static_volume:/static:ro + - media_volume:/media:ro + depends_on: + - web + - flower + networks: + - gallery_network + +volumes: + postgres_data: + redis_data: + seaweedfs_data: + static_volume: + media_volume: + +configs: + gallery_config: + # external: true + file: ./config.yaml + +networks: + gallery_network: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a7c8f4b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,218 @@ +# Main docker-compose.yml - Use for local development with all services +# For production split deployment: +# - VPS: Use docker-compose.vps.yml +# - Local GPU: Use docker-compose.gpu.yml +# See DEPLOYMENT.md for details + +version: '3.8' + +services: + db: + image: postgres:16-alpine + container_name: gallery_db + environment: + POSTGRES_DB: ${DB_NAME:-gallery} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "${DB_PORT:-5432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - gallery_network + + redis: + image: redis:7-alpine + container_name: gallery_redis + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - gallery_network + + seaweedfs: + image: chrislusf/seaweedfs:latest + container_name: gallery_seaweedfs + command: "server -master.port=9333 -volume.port=8080 -filer.port=8888 -s3.port=8333" + ports: + - "9333:9333" # Master + - "8080:8080" # Volume + - "8888:8888" # Filer + - "8333:8333" # S3 + volumes: + - seaweedfs_data:/data + networks: + - gallery_network + healthcheck: + test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:9333/dir/status || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + web: + build: . + container_name: gallery_web + command: sh -c "python manage.py migrate --noinput && gunicorn --bind 0.0.0.0:8000 --workers ${GUNICORN_WORKERS:-4} --timeout ${GUNICORN_TIMEOUT:-120} config.wsgi:application" + volumes: + - ./app:/app + - static_volume:/app/staticfiles + - media_volume:/app/media + configs: + - source: gallery_config + target: /app/config.yaml + ports: + - "8000:8000" + env_file: + - .env + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + seaweedfs: + condition: service_healthy + networks: + - gallery_network + + celery: + build: . + container_name: gallery_celery + command: celery -A config worker --loglevel=info --concurrency=${CELERY_CPU_WORKER_CONCURRENCY:-4} --queues=cpu + volumes: + - ./app:/app + - media_volume:/app/media + configs: + - source: gallery_config + target: /app/config.yaml + env_file: + - .env + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - gallery_network + + celery-gpu: + build: + context: . + dockerfile: Dockerfile.celery + container_name: gallery_celery_gpu + command: celery -A config worker --loglevel=info --concurrency=${CELERY_GPU_WORKER_CONCURRENCY:-2} --queues=gpu + volumes: + - ./app:/app + - media_volume:/app/media + configs: + - source: gallery_config + target: /app/config.yaml + env_file: + - .env + environment: + - NVIDIA_VISIBLE_DEVICES=all + - NVIDIA_DRIVER_CAPABILITIES=compute,utility + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - gallery_network + + celery-beat: + build: . + container_name: gallery_celery_beat + command: celery -A config beat --loglevel=info + volumes: + - ./app:/app + configs: + - source: gallery_config + target: /app/config.yaml + env_file: + - .env + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - gallery_network + + flower: + build: . + container_name: gallery_flower + command: > + sh -c " + if [ -n \"$$FLOWER_USERNAME\" ] && [ -n \"$$FLOWER_PASSWORD\" ]; then + celery -A config flower --port=5555 --basic_auth=$$FLOWER_USERNAME:$$FLOWER_PASSWORD + else + echo 'ERROR: FLOWER_USERNAME and FLOWER_PASSWORD must be set for security!' + exit 1 + fi + " + ports: + - "${FLOWER_PORT:-5555}:5555" + configs: + - source: gallery_config + target: /app/config.yaml + env_file: + - .env + depends_on: + - celery + - celery-gpu + - redis + networks: + - gallery_network + + nginx: + image: nginx:alpine + container_name: gallery_nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + - static_volume:/static:ro + - media_volume:/media:ro + depends_on: + - web + - flower + networks: + - gallery_network + +volumes: + postgres_data: + redis_data: + seaweedfs_data: + static_volume: + media_volume: + +configs: + gallery_config: + file: ./config.yaml + +networks: + gallery_network: + driver: bridge diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..d06578a --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,270 @@ +# Deployment Guide - Split Architecture with WireGuard VPN + +This guide explains how to deploy the gallery application across two machines using WireGuard VPN for secure connections: +- **VPS (Cloud Server)**: Web server, database, Redis, CPU Celery workers +- **Local Laptop**: GPU Celery worker for YOLO and PaddleOCR + +**⚠️ Important:** This deployment uses WireGuard VPN to securely connect the local GPU worker to VPS services. Redis and PostgreSQL are NOT exposed to the internet. See [WIREGUARD_SETUP.md](WIREGUARD_SETUP.md) for detailed WireGuard configuration. + +## Architecture Overview + +``` +┌─────────────────────────────────┐ +│ VPS (Cloud Server) │ +│ ┌─────────┐ ┌──────────┐ │ +│ │ Web │ │ Nginx │ │ +│ └─────────┘ └──────────┘ │ +│ ┌─────────┐ ┌──────────┐ │ +│ │ DB │ │ Redis │◄────┼──┐ +│ └─────────┘ └──────────┘ │ │ +│ ┌─────────┐ ┌──────────┐ │ │ +│ │ Celery │ │ Celery │ │ │ +│ │ (CPU) │ │ Beat │ │ │ +│ └─────────┘ └──────────┘ │ │ +│ ┌─────────┐ │ │ +│ │ Flower │ │ │ +│ └─────────┘ │ │ +└─────────────────────────────────┘ │ + │ + │ Redis Connection + │ (Port 6380) + │ +┌─────────────────────────────────┐ │ +│ Local Laptop (GPU) │ │ +│ ┌──────────────┐ │ │ +│ │ Celery-GPU │────────────────┘ │ +│ │ (YOLO/OCR) │ │ +│ └──────────────┘ │ +└─────────────────────────────────┘ +``` + +## Prerequisites + +### VPS Requirements +- Docker and Docker Compose installed +- Ports open: 80, 443, 51820 (WireGuard UDP), 8000 (Web) +- WireGuard installed and configured (see [WIREGUARD_SETUP.md](WIREGUARD_SETUP.md)) +- For HTTPS: SSL certificate and nginx SSL config (see [SSL_NGINX.md](SSL_NGINX.md)) +- Firewall configured to allow WireGuard port (51820/UDP) from your local IP + +### Local Laptop Requirements +- Docker and Docker Compose installed +- NVIDIA GPU with drivers installed +- NVIDIA Container Toolkit installed +- WSL2 (if on Windows) +- WireGuard installed and configured (see [WIREGUARD_SETUP.md](WIREGUARD_SETUP.md)) +- WireGuard VPN connection to VPS established + +## VPS Setup + +1. **Clone the repository on VPS:** + ```bash + git clone + cd gallery + ``` + +2. **Create `.env` file:** + ```bash + cp .env.example .env + ``` + +3. **Configure `.env` for VPS:** + ```env + # Database + DB_NAME=gallery + DB_USER=postgres + DB_PASSWORD=your_secure_password + DB_HOST=db + DB_PORT=5432 + + # Redis (internal) + REDIS_PORT=6379 + REDIS_EXTERNAL_PORT=6380 + + # Celery + CELERY_BROKER_URL=redis://redis:6379/0 + CELERY_RESULT_BACKEND=redis://redis:6379/0 + + # Flower (monitoring) + FLOWER_USERNAME=admin + FLOWER_PASSWORD=your_secure_password + FLOWER_PORT=5555 + ``` + +4. **Start services:** + ```bash + docker-compose -f docker-compose.vps.yml up -d + ``` + +5. **Verify services are running:** + ```bash + docker-compose -f docker-compose.vps.yml ps + ``` + +6. **Configure PostgreSQL for remote access (if GPU worker needs DB access):** + ```bash + # Note: GPU worker typically only needs Redis connection + # Database access is usually only needed if tasks directly query DB + # If needed, configure PostgreSQL to accept remote connections + ``` + +7. **Get your VPS IP address:** + ```bash + # On Linux + hostname -I + # Or check your VPS provider's dashboard + ``` + +## Local Laptop Setup + +1. **Clone the repository on local machine:** + ```bash + git clone + cd gallery + ``` + +2. **Create `.env` file:** + ```bash + cp .env.example .env + ``` + +3. **Configure WireGuard VPN first:** + - Follow the instructions in [WIREGUARD_SETUP.md](WIREGUARD_SETUP.md) + - Ensure WireGuard connection is established and tested + - Verify you can ping `10.0.0.1` from your local machine + +4. **Configure `.env` for local GPU worker:** + ```env + # VPS Connection via WireGuard VPN (10.0.0.1 is the VPS WireGuard IP) + CELERY_BROKER_URL=redis://10.0.0.1:6379/0 + CELERY_RESULT_BACKEND=redis://10.0.0.1:6379/0 + + # Database connection to VPS via WireGuard + DATABASE_HOST=10.0.0.1 + DATABASE_PORT=5432 + DATABASE_NAME=gallery + DATABASE_USER=postgres + DATABASE_PASSWORD=your_secure_password + + # Celery GPU Worker + CELERY_GPU_WORKER_CONCURRENCY=2 + ``` + +5. **Update `config.yaml` or use environment variables (optional):** + ```yaml + celery: + broker_url: "redis://10.0.0.1:6379/0" + result_backend: "redis://10.0.0.1:6379/0" + + database: + host: "10.0.0.1" + port: 5432 + name: "gallery" + user: "postgres" + password: "your_secure_password" + ``` + +6. **Start GPU worker:** + ```bash + docker-compose -f docker-compose.gpu.yml up -d + ``` + +7. **Verify GPU worker is running:** + ```bash + docker-compose -f docker-compose.gpu.yml ps + docker-compose -f docker-compose.gpu.yml logs celery-gpu + ``` + +## Security Considerations + +### WireGuard VPN Security +- **Redis and PostgreSQL are NOT exposed to the internet** - they are only accessible via WireGuard VPN +- WireGuard provides encrypted, authenticated connections +- Only the WireGuard port (51820/UDP) needs to be open on the VPS +- Configure firewall to only allow WireGuard port from your local IP + +### Redis Security +- Redis is accessible only via WireGuard VPN (10.0.0.1:6379) +- **Optional but recommended**: Add Redis password authentication + ```bash + # In docker-compose.vps.yml, update Redis command: + command: redis-server --appendonly yes --bind 0.0.0.0 --requirepass YOUR_REDIS_PASSWORD + ``` +- Then update `CELERY_BROKER_URL` to: `redis://:YOUR_REDIS_PASSWORD@10.0.0.1:6379/0` + +### Database Security +- PostgreSQL is accessible only via WireGuard VPN (10.0.0.1:5432) +- Use strong passwords +- Consider using SSL connections for database (even within VPN) + +### Firewall Rules +On VPS, configure firewall to only allow: +- Port 80/443: From anywhere (web traffic) +- Port 51820/UDP: Only from your local laptop IP (WireGuard VPN) +- Port 8000: Optional, for direct web access (or use nginx reverse proxy) + +## Testing the Connection + +### Test WireGuard Connection +```bash +# From local machine, ping VPS WireGuard IP +ping 10.0.0.1 + +# Check WireGuard status +sudo wg show +``` + +### Test Redis Connection from Local Machine +```bash +# Install redis-cli if needed +# On Ubuntu/Debian: +sudo apt-get install redis-tools + +# Test connection via WireGuard +redis-cli -h 10.0.0.1 -p 6379 ping +``` + +### Test Database Connection +```bash +# From local machine via WireGuard +psql -h 10.0.0.1 -p 5432 -U postgres -d gallery +``` + +### Monitor Workers +- Access Flower on VPS: `http://YOUR_VPS_IP:5555` +- You should see both CPU and GPU workers listed + +## Troubleshooting + +### GPU Worker Can't Connect to Redis +- **First, verify WireGuard is connected:** `sudo wg show` and `ping 10.0.0.1` +- Check Redis is running: `docker-compose -f docker-compose.vps.yml ps redis` +- Test connection: `redis-cli -h 10.0.0.1 -p 6379 ping` +- Check WireGuard routing: `ip route show` + +### GPU Worker Can't Connect to Database +- **First, verify WireGuard is connected:** `sudo wg show` and `ping 10.0.0.1` +- Check PostgreSQL is running: `docker-compose -f docker-compose.vps.yml ps db` +- Test connection: `psql -h 10.0.0.1 -p 5432 -U postgres -d gallery` +- Verify Docker network allows WireGuard access + +### Tasks Not Processing on GPU Worker +- Verify tasks are routed to 'gpu' queue +- Check Celery routing configuration in `settings.py` +- Monitor logs: `docker-compose -f docker-compose.gpu.yml logs -f celery-gpu` + +## Maintenance + +### Updating Code +1. Pull latest code on both machines +2. Rebuild and restart: + - VPS: `docker-compose -f docker-compose.vps.yml up -d --build` + - Local: `docker-compose -f docker-compose.gpu.yml up -d --build` + +### Viewing Logs +- VPS: `docker-compose -f docker-compose.vps.yml logs -f` +- Local: `docker-compose -f docker-compose.gpu.yml logs -f celery-gpu` + +### Stopping Services +- VPS: `docker-compose -f docker-compose.vps.yml down` +- Local: `docker-compose -f docker-compose.gpu.yml down` diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md new file mode 100644 index 0000000..455c519 --- /dev/null +++ b/docs/FRONTEND.md @@ -0,0 +1,128 @@ +# Frontend Documentation - Gallery App + +## Overview + +The Gallery app uses a **Multi-Page Application (MPA)** approach with Django templates and HTMX for interactive updates. + +## Architecture + +- **Django Templates**: Server-rendered HTML pages +- **HTMX**: For interactive updates without full page refresh +- **Tailwind CSS**: For styling (via CDN) +- **No JavaScript Framework**: Pure Django + HTMX + +## Features + +### HTMX-Powered Interactions + +1. **Toggle Favorite** (⭐/☆) + - Galleries and Pictures + - Updates instantly without page refresh + - Uses `hx-post` to toggle favorite status + +2. **Add/Remove Tags** + - Interactive tag management + - Add tags via inline form + - Remove tags with × button + - Updates in real-time + +3. **Search and Filter** + - Server-side filtering + - Full page navigation (traditional MPA) + +## Template Structure + +``` +gallery/templates/gallery/ +├── base.html # Base template with navigation +├── gallery_list.html # List of galleries +├── gallery_detail.html # Gallery details with albums +├── gallery_form.html # Create/Edit gallery form +├── album_detail.html # Album with pictures grid +├── album_form.html # Create/Edit album form +├── picture_detail.html # Picture view with metadata +└── partials/ + ├── favorite_button.html # HTMX favorite toggle button + └── tags_list.html # HTMX tags list with add/remove +``` + +## HTMX Endpoints + +### Gallery Endpoints + +- `POST /galleries//toggle-favorite/` - Toggle favorite status +- `POST /galleries//add-tag/` - Add tag to gallery +- `DELETE /galleries//remove-tag/` - Remove tag from gallery + +### Album Endpoints + +- `POST /albums//add-tag/` - Add tag to album +- `DELETE /albums//remove-tag/` - Remove tag from album + +### Picture Endpoints + +- `POST /pictures//toggle-favorite/` - Toggle favorite status + +## HTMX Usage Examples + +### Toggle Favorite + +```html + +``` + +### Remove Tag + +```html + +``` + +### Add Tag + +```html +
+ + +
+``` + +## URL Structure + +- `/` - Gallery list +- `/galleries/create/` - Create gallery +- `/galleries//` - Gallery detail +- `/galleries//edit/` - Edit gallery +- `/albums//` - Album detail +- `/pictures//` - Picture detail + +## Styling + +- **Tailwind CSS** via CDN +- Custom CSS in `static/gallery/css/style.css` +- Responsive design (mobile-first) + +## CSRF Protection + +HTMX requests include CSRF tokens automatically via: +1. Global event listener in `base.html` +2. `hx-headers` attribute on specific elements +3. Django's CSRF middleware + +## Future Enhancements + +- Image upload with progress bar (HTMX) +- Inline editing (HTMX) +- Infinite scroll for pictures (HTMX) +- Drag-and-drop for organizing (vanilla JS + HTMX) +- Real-time search suggestions (HTMX) diff --git a/docs/NGINX_SIGNED_URLS.md b/docs/NGINX_SIGNED_URLS.md new file mode 100644 index 0000000..04fc02a --- /dev/null +++ b/docs/NGINX_SIGNED_URLS.md @@ -0,0 +1,141 @@ +# Nginx Signed URL Configuration + +This document explains how to configure Nginx to validate signed URLs for SeaweedFS media files. + +## Overview + +Django generates signed URLs for pictures stored in SeaweedFS. Nginx validates these URLs using the `secure_link` module before serving the files. + +## URL Format + +Signed URLs have the format: +``` +/media/{file_id}?st={signature}&e={expires_timestamp} +``` + +Where: +- `st`: HMAC-SHA256 signature (base64url encoded) +- `e`: Unix timestamp when URL expires + +## Nginx Configuration + +The Nginx configuration uses the `secure_link` module to validate URLs: + +```nginx +location /media/ { + secure_link $arg_st,$arg_e; + secure_link_md5 "$uri$secure_link_expires$secure_link_secret"; + + if ($secure_link = "") { + return 403; + } + if ($secure_link = "0") { + return 403; + } + + # Proxy to SeaweedFS or serve locally + proxy_pass http://seaweedfs:8888/; + # OR + alias /media/; +} +``` + +## Setting the Secret Key + +The `secure_link_secret` must match the secret used by Django. Set it in Nginx: + +### Option 1: Environment Variable (Recommended) + +In `docker-compose.yml`: +```yaml +nginx: + environment: + - SECURE_LINK_SECRET=${GALLERY_SIGNED_URL_SECRET:-${SECRET_KEY}} +``` + +Then in Nginx config: +```nginx +secure_link_secret $SECURE_LINK_SECRET; +``` + +### Option 2: Direct Configuration + +```nginx +secure_link_secret "your-secret-key-here"; +``` + +**Important:** This secret must match: +- Django `GALLERY_SIGNED_URL_SECRET` setting, OR +- Django `SECRET_KEY` if `GALLERY_SIGNED_URL_SECRET` is not set + +## How It Works + +1. **Django generates signed URL:** + ```python + from gallery.utils import generate_signed_url + signed = generate_signed_url(file_id, expires_in=3600) + # Returns: /media/{file_id}?st={signature}&e={expires} + ``` + +2. **Browser requests the URL:** + ``` + GET /media/abc123?st=xyz&e=1234567890 + ``` + +3. **Nginx validates:** + - Extracts `st` (signature) and `e` (expires) from query params + - Recomputes signature using: `HMAC-SHA256(uri + expires + secret)` + - Compares with provided signature + - Checks if current time < expires timestamp + - Returns 403 if invalid or expired + +4. **If valid, serves file:** + - Proxies to SeaweedFS, OR + - Serves from local storage + +## Testing + +### Generate a test URL in Django shell: +```python +from gallery.utils import generate_signed_url +signed = generate_signed_url("test-file-id", expires_in=3600) +print(signed['url']) +``` + +### Test with curl: +```bash +# Valid URL +curl "http://localhost/media/test-file-id?st=...&e=..." + +# Invalid signature (should return 403) +curl "http://localhost/media/test-file-id?st=wrong&e=1234567890" + +# Expired URL (should return 403) +curl "http://localhost/media/test-file-id?st=...&e=1" +``` + +## Security Notes + +1. **Secret Key:** Keep the secret key secure and never expose it +2. **Expiration:** URLs expire after the specified time (default 1 hour) +3. **HTTPS:** Use HTTPS in production to prevent URL interception +4. **Rate Limiting:** Consider adding rate limiting for media endpoints + +## Troubleshooting + +### 403 Forbidden Errors + +1. Check that `secure_link_secret` matches Django's secret +2. Verify URL format is correct +3. Check if URL has expired +4. Verify Nginx has `secure_link` module enabled: + ```bash + nginx -V 2>&1 | grep -o with-http_secure_link_module + ``` + +### Module Not Found + +If `secure_link` module is not available, you may need to: +- Recompile Nginx with `--with-http_secure_link_module` +- Use a different Nginx image that includes the module +- Use an alternative validation method (e.g., Django middleware) diff --git a/docs/SEAWEEDFS_AUTH.md b/docs/SEAWEEDFS_AUTH.md new file mode 100644 index 0000000..5b90314 --- /dev/null +++ b/docs/SEAWEEDFS_AUTH.md @@ -0,0 +1,186 @@ +# SeaweedFS S3 Authentication Setup + +This guide explains how to set up authentication for SeaweedFS S3 API in the Gallery application. + +## Overview + +SeaweedFS provides S3-compatible API with embedded IAM (Identity and Access Management) for authentication. Unlike traditional S3 services, SeaweedFS doesn't have default credentials - you need to create access keys via the IAM API after starting the service. + +## Quick Start + +### 1. Start SeaweedFS + +Start your Docker services: + +```bash +# For VPS deployment +make docker-up-vps + +# Or for local development +make docker-up +``` + +Wait for SeaweedFS to be ready (check with `docker-compose ps` or `make docker-ps`). + +### 2. Create Access Keys + +Use the provided script to create access keys: + +```bash +# Using the script (recommended) +python scripts/setup_seaweedfs_auth.py + +# Or with custom endpoint +python scripts/setup_seaweedfs_auth.py --endpoint http://localhost:8333 + +# Output in .env format +python scripts/setup_seaweedfs_auth.py --output-format env + +# Output in YAML format +python scripts/setup_seaweedfs_auth.py --output-format yaml +``` + +### 3. Configure Credentials + +Add the generated credentials to your configuration: + +**Option A: Using .env file** + +```bash +# Add to .env file +storage.s3.access_key_id=your-access-key-id +storage.s3.secret_access_key=your-secret-access-key +storage.use_s3=True +``` + +**Option B: Using config.yaml** + +```yaml +storage: + use_s3: true + s3: + access_key_id: "your-access-key-id" + secret_access_key: "your-secret-access-key" + endpoint_url: "http://seaweedfs:8333" + bucket_name: "gallery" + region_name: "us-east-1" + use_ssl: false +``` + +### 4. Restart Services + +Restart your web service to apply the new credentials: + +```bash +docker-compose restart web +# Or +make docker-up-vps +``` + +## Manual Setup (Alternative) + +If you prefer to create access keys manually: + +### Using curl + +```bash +# Create access key for 'admin' identity +curl -X POST "http://localhost:8333/iam/createAccessKey?identity=admin" + +# Response will be JSON: +# { +# "accessKeyId": "your-access-key-id", +# "secretAccessKey": "your-secret-access-key" +# } +``` + +### Using Python + +```python +import requests + +response = requests.post("http://localhost:8333/iam/createAccessKey?identity=admin") +data = response.json() +print(f"Access Key ID: {data['accessKeyId']}") +print(f"Secret Access Key: {data['secretAccessKey']}") +``` + +## IAM API Endpoints + +SeaweedFS IAM API provides several endpoints for managing access keys: + +- **Create Access Key**: `POST /iam/createAccessKey?identity=` +- **List Access Keys**: `GET /iam/listAccessKeys?identity=` +- **Delete Access Key**: `DELETE /iam/deleteAccessKey?identity=&accessKeyId=` + +For more details, see [SeaweedFS IAM API documentation](https://github.com/seaweedfs/seaweedfs/wiki/Amazon-IAM-API). + +## Security Considerations + +1. **Store Credentials Securely**: Never commit access keys to version control. Use `.env` files (which are in `.gitignore`) or secure secret management. + +2. **Network Security**: + - SeaweedFS is only accessible within the Docker network (`gallery_network`) + - For VPS deployment, use WireGuard VPN to secure access + - Don't expose SeaweedFS ports publicly + +3. **Identity Management**: + - Use different identities for different applications or environments + - Rotate access keys periodically + - Delete unused access keys + +4. **Production Setup**: + - Use strong, randomly generated access keys + - Consider using environment variables from a secrets manager + - Enable SSL/TLS if accessing SeaweedFS over untrusted networks + +## Troubleshooting + +### Error: "Could not connect to SeaweedFS" + +- Ensure SeaweedFS container is running: `docker-compose ps` +- Check if the endpoint URL is correct (default: `http://localhost:8333`) +- For Docker network access, use `http://seaweedfs:8333` from within containers + +### Error: "HTTP 404 - IAM API not found" + +- Ensure you're using SeaweedFS version 3.x or later (IAM is enabled by default) +- Check that the S3 port (8333) is correctly exposed +- Verify the IAM API is enabled (it should be by default) + +### Access Keys Not Working + +- Verify credentials are correctly set in `.env` or `config.yaml` +- Ensure `storage.use_s3=True` is set +- Check Django logs for authentication errors +- Verify the bucket exists: `curl http://localhost:8333/s3/buckets` + +### Creating Bucket + +If the bucket doesn't exist, create it: + +```bash +# Using curl +curl -X PUT "http://localhost:8333/s3/bucket?name=gallery" + +# Or using AWS CLI (if configured) +aws --endpoint-url=http://localhost:8333 s3 mb s3://gallery +``` + +## Makefile Commands + +For convenience, you can use: + +```bash +# Create SeaweedFS access keys +make seaweedfs-auth + +# Create keys with custom endpoint +make seaweedfs-auth ENDPOINT=http://seaweedfs:8333 +``` + +## References + +- [SeaweedFS S3 API Documentation](https://github.com/seaweedfs/seaweedfs/wiki/Amazon-S3-API) +- [SeaweedFS IAM API Documentation](https://github.com/seaweedfs/seaweedfs/wiki/Amazon-IAM-API) +- [Django Storages Documentation](https://django-storages.readthedocs.io/) diff --git a/docs/SECURITY_EXPLANATION.md b/docs/SECURITY_EXPLANATION.md new file mode 100644 index 0000000..5f23414 --- /dev/null +++ b/docs/SECURITY_EXPLANATION.md @@ -0,0 +1,271 @@ +# Security Explanation: How Redis and PostgreSQL are Protected + +## Overview + +With WireGuard VPN, Redis and PostgreSQL are **NOT exposed to the public internet**. They are only accessible through an encrypted VPN tunnel. Here's how it works: + +## Network Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ INTERNET (Public) │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ VPS (Cloud Server) │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────┐ │ │ +│ │ │ Firewall / Network Security │ │ │ +│ │ │ ✅ Port 80/443 (HTTP/HTTPS) - OPEN │ │ │ +│ │ │ ✅ Port 51820/UDP (WireGuard) - OPEN │ │ │ +│ │ │ ❌ Port 6379 (Redis) - CLOSED │ │ │ +│ │ │ ❌ Port 5432 (PostgreSQL) - CLOSED │ │ │ +│ │ └──────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────┐ │ │ +│ │ │ WireGuard VPN Server │ │ │ +│ │ │ IP: 10.0.0.1 │ │ │ +│ │ │ 🔒 Encrypted Tunnel Entry Point │ │ │ +│ │ └──────────────────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ │ 🔒 Encrypted VPN Tunnel │ │ +│ │ │ │ │ +│ │ ┌──────────▼──────────────────────────────┐ │ │ +│ │ │ Docker Network (gallery_network) │ │ │ +│ │ │ ┌──────────┐ ┌──────────┐ │ │ │ +│ │ │ │ Redis │ │PostgreSQL│ │ │ │ +│ │ │ │ :6379 │ │ :5432 │ │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ │ Only accessible from │ │ │ +│ │ │ │ WireGuard network (10.0.0.0/24) │ │ │ +│ │ │ └──────────┘ └──────────┘ │ │ │ +│ │ └────────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ 🔒 WireGuard Encrypted Tunnel │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Local Laptop (Your Machine) │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────┐ │ │ +│ │ │ WireGuard VPN Client │ │ │ +│ │ │ IP: 10.0.0.2 │ │ │ +│ │ │ 🔒 Encrypted Tunnel Exit Point │ │ │ +│ │ └──────────────────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ ┌───────────▼──────────────────────────────┐ │ │ +│ │ │ Docker Container (celery-gpu) │ │ │ +│ │ │ Connects to: │ │ │ +│ │ │ - redis://10.0.0.1:6379 │ │ │ +│ │ │ - postgres://10.0.0.1:5432 │ │ │ +│ │ └──────────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +## Security Layers + +### Layer 1: Network Firewall (VPS) + +**What's Exposed:** +- ✅ Port 80/443: HTTP/HTTPS (web traffic) - **Necessary for public access** +- ✅ Port 51820/UDP: WireGuard VPN - **Only way to access databases** + +**What's NOT Exposed:** +- ❌ Port 6379 (Redis): **Completely blocked from internet** +- ❌ Port 5432 (PostgreSQL): **Completely blocked from internet** + +**Result:** Even if someone knows your VPS IP address, they **cannot** directly connect to Redis or PostgreSQL. These ports are not reachable from the internet. + +### Layer 2: WireGuard VPN Authentication + +**How WireGuard Protects:** + +1. **Cryptographic Authentication:** + - Each peer (server and client) has a public/private key pair + - Only devices with the correct private key can join the VPN + - Keys are cryptographically strong (256-bit) + +2. **Encryption:** + - All traffic through WireGuard is encrypted using modern cryptography + - Even if someone intercepts the traffic, they can't read it + +3. **IP Whitelisting:** + - Only devices with WireGuard client configured can get a VPN IP (10.0.0.2) + - The server only accepts connections from authenticated clients + +**To Access Redis/PostgreSQL, an Attacker Would Need:** +1. Your VPS IP address ✅ (publicly known) +2. WireGuard server private key ❌ (only on VPS, never shared) +3. WireGuard client private key ❌ (only on your local machine) +4. Your local machine's WireGuard configuration ❌ (encrypted, password-protected) + +### Layer 3: Docker Network Isolation + +**Internal Network:** +- Redis and PostgreSQL run inside Docker containers +- They're on a private Docker network (`gallery_network`) +- They bind to `0.0.0.0` (all interfaces) but are only accessible through: + - Other Docker containers on the same network + - WireGuard VPN clients (10.0.0.1 can route to Docker network) + +**Result:** Even if someone gains access to your VPS, they would still need: +- WireGuard VPN access to reach the Docker network +- Or direct shell access to the VPS to access containers + +## Comparison: Before vs After WireGuard + +### ❌ BEFORE (Insecure - Exposed to Internet) + +``` +Internet → VPS Firewall → Redis (Port 6379) ← Anyone can try to connect! +Internet → VPS Firewall → PostgreSQL (Port 5432) ← Anyone can try to connect! +``` + +**Risks:** +- Port scanners can discover open Redis/PostgreSQL ports +- Brute force attacks on authentication +- Unencrypted connections (unless SSL configured) +- Vulnerabilities in Redis/PostgreSQL exposed to internet +- DDoS attacks possible + +### ✅ AFTER (Secure - WireGuard VPN) + +``` +Internet → VPS Firewall → ❌ Redis (Port 6379) - BLOCKED +Internet → VPS Firewall → ❌ PostgreSQL (Port 5432) - BLOCKED + +Your Laptop → WireGuard VPN → VPS WireGuard (10.0.0.1) → Redis/PostgreSQL + ↑ + Only accessible through + encrypted VPN tunnel +``` + +**Benefits:** +- Ports not visible to internet scanners +- No direct attack surface +- All traffic encrypted +- Authentication required (WireGuard keys) +- Only your authorized device can connect + +## What Attackers See + +### Port Scan from Internet: +```bash +# Attacker runs: nmap -p 6379,5432 YOUR_VPS_IP +# Result: +6379/tcp closed # ✅ Not accessible +5432/tcp closed # ✅ Not accessible +51820/udp open # WireGuard - but requires keys to connect +``` + +**Attacker cannot:** +- Connect to Redis +- Connect to PostgreSQL +- See what services are running on those ports +- Attempt brute force attacks + +**Attacker can:** +- See that WireGuard is running (port 51820) +- But cannot connect without valid keys + +## Additional Security Measures (Recommended) + +Even with WireGuard, you should still: + +### 1. Redis Password Authentication (Optional but Recommended) +```yaml +# In docker-compose.vps.yml +redis: + command: redis-server --appendonly yes --bind 0.0.0.0 --requirepass YOUR_STRONG_PASSWORD +``` + +Then in `.env`: +```env +celery.broker_url=redis://:YOUR_STRONG_PASSWORD@10.0.0.1:6379/0 +``` + +**Why:** Defense in depth - even if WireGuard is compromised, Redis still requires a password. + +### 2. PostgreSQL Strong Passwords +```env +database.password=your_very_strong_password_here +``` + +**Why:** Defense in depth - even if WireGuard is compromised, PostgreSQL still requires authentication. + +### 3. Firewall Rules +```bash +# Only allow WireGuard port from your IP +sudo ufw allow from YOUR_LOCAL_IP to any port 51820 proto udp +``` + +**Why:** Limits who can even attempt to connect to WireGuard. + +### 4. Regular Updates +- Keep WireGuard updated +- Keep Redis and PostgreSQL updated +- Monitor for security advisories + +## Security Summary + +| Aspect | Status | Explanation | +|--------|--------|-------------| +| **Redis exposed to internet?** | ❌ NO | Only accessible via WireGuard VPN (10.0.0.1:6379) | +| **PostgreSQL exposed to internet?** | ❌ NO | Only accessible via WireGuard VPN (10.0.0.1:5432) | +| **Traffic encrypted?** | ✅ YES | All traffic through WireGuard is encrypted | +| **Authentication required?** | ✅ YES | WireGuard requires cryptographic keys | +| **Ports visible to scanners?** | ❌ NO | Ports 6379 and 5432 are closed on firewall | +| **Can attackers brute force?** | ❌ NO | They can't even reach the services | +| **Can attackers intercept traffic?** | ❌ NO | Traffic is encrypted through VPN tunnel | + +## Real-World Attack Scenarios + +### Scenario 1: Port Scanner Attack +``` +Attacker: Scans YOUR_VPS_IP for common database ports +Result: Ports 6379 and 5432 show as CLOSED +Outcome: ✅ Attacker gives up, can't find services +``` + +### Scenario 2: Brute Force Attack +``` +Attacker: Tries to brute force Redis password +Result: Can't connect - port is not accessible +Outcome: ✅ Attack fails before it even starts +``` + +### Scenario 3: Man-in-the-Middle Attack +``` +Attacker: Intercepts network traffic between you and VPS +Result: Sees encrypted WireGuard packets +Outcome: ✅ Can't decrypt without WireGuard keys +``` + +### Scenario 4: WireGuard Key Theft +``` +Attacker: Steals your WireGuard private key +Result: Can connect to VPN, but still needs: + - Redis password (if configured) + - PostgreSQL password +Outcome: ⚠️ Partial access - but still requires database credentials +``` + +**Mitigation:** Use strong database passwords even with WireGuard! + +## Conclusion + +**Redis and PostgreSQL are secure because:** + +1. ✅ **They are NOT exposed to the internet** - ports are closed on firewall +2. ✅ **Only accessible through encrypted VPN tunnel** - WireGuard provides encryption +3. ✅ **Authentication required** - WireGuard keys prevent unauthorized access +4. ✅ **Network isolation** - Docker network adds another layer +5. ✅ **Defense in depth** - Multiple security layers work together + +**Without WireGuard keys, an attacker cannot:** +- See that Redis/PostgreSQL exist +- Connect to them +- Attempt to exploit them +- Intercept or read traffic + +This is **significantly more secure** than exposing these services directly to the internet! diff --git a/docs/SSL_NGINX.md b/docs/SSL_NGINX.md new file mode 100644 index 0000000..95a1085 --- /dev/null +++ b/docs/SSL_NGINX.md @@ -0,0 +1,140 @@ +# SSL/TLS for Nginx + +This guide explains how to obtain an SSL certificate and configure nginx to serve the gallery over HTTPS. + +## Overview + +- **HTTP (port 80)** is always enabled via `nginx/nginx.conf`. +- **HTTPS (port 443)** is optional: enable it by adding certificates and an SSL server block. +- Certificates live in `nginx/ssl/`; the HTTPS server block is in `nginx/conf.d/ssl.conf` (copy from `ssl.conf.example`). + +Docker Compose already mounts `nginx/conf.d` and `nginx/ssl` into the nginx container. Enable HTTPS only when you have placed valid certs and created `ssl.conf`. + +--- + +## 1. Generate an SSL certificate + +### Option A: Let’s Encrypt (production, free) + +Use Certbot on the host (or in a container) to get a certificate for your domain. The server must be reachable at your domain on port 80 (and optionally 443) for HTTP-01 challenge. + +**On the server (e.g. Ubuntu/Debian):** + +```bash +sudo apt update +sudo apt install certbot +sudo certbot certonly --standalone -d your-domain.com +``` + +Certbot will write files under `/etc/letsencrypt/live/your-domain.com/`: + +- `fullchain.pem` – certificate + chain +- `privkey.pem` – private key + +**Copy them into the project so nginx in Docker can read them:** + +```bash +mkdir -p nginx/ssl +sudo cp /etc/letsencrypt/live/your-domain.com/fullchain.pem nginx/ssl/ +sudo cp /etc/letsencrypt/live/your-domain.com/privkey.pem nginx/ssl/ +sudo chown "$(whoami):" nginx/ssl/*.pem +``` + +**Renewal:** Certbot can renew via cron or systemd timer. After renewal, copy the new files into `nginx/ssl/` again (or use a bind mount from `/etc/letsencrypt` into the nginx container and point nginx at those paths). + +**Using Certbot in Docker (alternative):** You can run Certbot in a container and use a volume for `nginx/ssl` so the nginx container and Certbot share the same cert directory. See [Certbot docs](https://certbot.eff.org/instructions) for Docker examples. + +### Option B: Self-signed certificate (development / testing) + +For local or internal use only: + +```bash +mkdir -p nginx/ssl +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout nginx/ssl/privkey.pem \ + -out nginx/ssl/fullchain.pem \ + -subj "/CN=localhost" +``` + +Browsers will show a security warning; accept the exception for testing. Do not use this cert for production. + +--- + +## 2. Enable HTTPS in nginx + +1. **Ensure certs are in place:** + + - `nginx/ssl/fullchain.pem` + - `nginx/ssl/privkey.pem` + +2. **Create the SSL server config:** + + ```bash + cp nginx/conf.d/ssl.conf.example nginx/conf.d/ssl.conf + ``` + +3. **Edit `nginx/conf.d/ssl.conf`:** + - Set `server_name` to your domain (e.g. `gallery.example.com`) or leave `_` for default. + - If your media signed-URL secret is not the placeholder, set `secure_link_secret` to match Django’s `GALLERY_SIGNED_URL_SECRET` (or the value used in `nginx/nginx.conf`). + +4. **Restart nginx:** + + ```bash + docker compose restart nginx + ``` + + Or, with the VPS compose file: + + ```bash + docker compose -f docker-compose.vps.yml restart nginx + ``` + +5. **Check:** Open `https://your-domain.com` (or `https://localhost` with a self-signed cert). Confirm the padlock and that static/media and the app load correctly. + +--- + +## 3. Redirect HTTP to HTTPS (optional) + +To send all HTTP traffic to HTTPS: + +1. **Edit `nginx/nginx.conf`.** + In the existing `server { listen 80; ... }` block, replace the `location / { ... }` (and other locations) with a redirect, or add at the top of that server block: + + ```nginx + return 301 https://$host$request_uri; + ``` + + So the block looks like: + + ```nginx + server { + listen 80; + server_name your-domain.com; # or _ + return 301 https://$host$request_uri; + } + ``` + +2. **Reload nginx:** + `docker compose restart nginx` (or your compose file). + +--- + +## 4. File layout summary + +| Path | Purpose | +|------|--------| +| `nginx/nginx.conf` | Main config; HTTP (80) and `include /etc/nginx/conf.d/*.conf` | +| `nginx/conf.d/ssl.conf.example` | Template HTTPS server; copy to `ssl.conf` and edit | +| `nginx/conf.d/ssl.conf` | Your HTTPS server block (created by you; not committed) | +| `nginx/ssl/fullchain.pem` | Certificate (or full chain) | +| `nginx/ssl/privkey.pem` | Private key | + +`nginx/ssl/` and `nginx/conf.d/ssl.conf` are listed in `.gitignore` so certificates and your SSL config are not committed. + +--- + +## 5. Troubleshooting + +- **nginx won’t start:** Ensure `nginx/ssl/fullchain.pem` and `nginx/ssl/privkey.pem` exist and are readable by the container. If HTTPS is not needed yet, remove or rename `nginx/conf.d/ssl.conf` so only `nginx.conf` is used. +- **Certificate not trusted (self-signed):** Expected for dev certs; add a browser exception. +- **Mixed content / redirect loops:** Set Django’s `SECURE_PROXY_SSL_HEADER` and use `https` in `STATIC_URL`/`MEDIA_URL` if you serve the app only over HTTPS. Ensure `X-Forwarded-Proto` is set (already in the provided `ssl.conf.example`). diff --git a/docs/WIREGUARD_SETUP.md b/docs/WIREGUARD_SETUP.md new file mode 100644 index 0000000..38a6519 --- /dev/null +++ b/docs/WIREGUARD_SETUP.md @@ -0,0 +1,381 @@ +# WireGuard VPN Setup Guide + +This guide explains how to set up WireGuard VPN to securely connect your local GPU worker to the VPS services without exposing Redis and PostgreSQL to the internet. + +## Architecture with WireGuard + +``` +┌─────────────────────────────────┐ +│ VPS (Cloud Server) │ +│ ┌──────────────────────────┐ │ +│ │ WireGuard Server │ │ +│ │ IP: 10.0.0.1/24 │ │ +│ └──────────────────────────┘ │ +│ ┌─────────┐ ┌──────────┐ │ +│ │ Web │ │ Nginx │ │ +│ └─────────┘ └──────────┘ │ +│ ┌─────────┐ ┌──────────┐ │ +│ │ DB │ │ Redis │ │ +│ │10.0.0.1 │ │10.0.0.1 │ │ +│ └─────────┘ └──────────┘ │ +│ ┌─────────┐ │ +│ │ Celery │ │ +│ │ (CPU) │ │ +│ └─────────┘ │ +└─────────────────────────────────┘ + ▲ + │ WireGuard VPN + │ (Encrypted Tunnel) + │ +┌─────────────────────────────────┐ +│ Local Laptop (GPU) │ +│ ┌──────────────────────────┐ │ +│ │ WireGuard Client │ │ +│ │ IP: 10.0.0.2/24 │ │ +│ └──────────────────────────┘ │ +│ ┌──────────────┐ │ +│ │ Celery-GPU │ │ +│ │ (YOLO/OCR) │ │ +│ └──────────────┘ │ +└─────────────────────────────────┘ +``` + +## VPS Setup (WireGuard Server) + +### 1. Install WireGuard + +```bash +# Ubuntu/Debian +sudo apt update +sudo apt install wireguard wireguard-tools + +# CentOS/RHEL +sudo yum install epel-release +sudo yum install wireguard-tools + +# Verify installation +wg --version +``` + +### 2. Enable IP Forwarding + +```bash +# Enable IP forwarding +echo "net.ipv4.ip_forward=1" | sudo tee -a /etc/sysctl.conf +sudo sysctl -p +``` + +### 3. Generate Server Keys + +```bash +# Create WireGuard directory +sudo mkdir -p /etc/wireguard +cd /etc/wireguard + +# Generate private key +sudo wg genkey | sudo tee server_private.key | sudo wg pubkey | sudo tee server_public.key + +# Set permissions +sudo chmod 600 server_private.key +``` + +### 4. Create Server Configuration + +```bash +sudo nano /etc/wireguard/wg0.conf +``` + +Add the following configuration: + +```ini +[Interface] +# Server private key (from server_private.key) +PrivateKey = YOUR_SERVER_PRIVATE_KEY_HERE + +# Server WireGuard IP address +Address = 10.0.0.1/24 + +# Listen on UDP port 51820 +ListenPort = 51820 + +# Firewall rules - allow forwarding +PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE + +[Peer] +# Client public key (will be added after generating client keys) +PublicKey = CLIENT_PUBLIC_KEY_WILL_BE_ADDED_HERE +# Client WireGuard IP +AllowedIPs = 10.0.0.2/32 +``` + +**Important:** Replace `eth0` with your actual network interface name (check with `ip addr` or `ifconfig`). + +### 5. Start WireGuard Server + +```bash +# Enable and start WireGuard +sudo systemctl enable wg-quick@wg0 +sudo systemctl start wg-quick@wg0 + +# Check status +sudo wg show +sudo systemctl status wg-quick@wg0 +``` + +### 6. Configure Firewall + +```bash +# Allow WireGuard port (51820/UDP) +sudo ufw allow 51820/udp + +# Or with iptables +sudo iptables -A INPUT -p udp --dport 51820 -j ACCEPT +``` + +## Local Machine Setup (WireGuard Client) + +### 1. Install WireGuard + +**Windows (WSL2):** +```bash +# In WSL2 +sudo apt update +sudo apt install wireguard wireguard-tools + +# Or use Windows WireGuard client from: https://www.wireguard.com/install/ +``` + +**Linux:** +```bash +sudo apt update +sudo apt install wireguard wireguard-tools +``` + +**macOS:** +```bash +brew install wireguard-tools +``` + +### 2. Generate Client Keys + +```bash +# Create WireGuard directory +sudo mkdir -p /etc/wireguard +cd /etc/wireguard + +# Generate private key +sudo wg genkey | sudo tee client_private.key | sudo wg pubkey | sudo tee client_public.key + +# Set permissions +sudo chmod 600 client_private.key + +# Display public key (you'll need this for the server) +sudo cat client_public.key +``` + +### 3. Add Client Public Key to Server + +On the VPS, add the client's public key to the server configuration: + +```bash +sudo nano /etc/wireguard/wg0.conf +``` + +Update the `[Peer]` section with the client's public key: + +```ini +[Peer] +PublicKey = YOUR_CLIENT_PUBLIC_KEY_HERE +AllowedIPs = 10.0.0.2/32 +``` + +Then restart WireGuard on the server: +```bash +sudo systemctl restart wg-quick@wg0 +``` + +### 4. Create Client Configuration + +On the local machine: + +```bash +sudo nano /etc/wireguard/wg0.conf +``` + +Add the following configuration: + +```ini +[Interface] +# Client private key +PrivateKey = YOUR_CLIENT_PRIVATE_KEY_HERE + +# Client WireGuard IP address +Address = 10.0.0.2/24 + +[Peer] +# Server public key (from server_public.key on VPS) +PublicKey = YOUR_SERVER_PUBLIC_KEY_HERE + +# VPS public IP address and WireGuard port +Endpoint = YOUR_VPS_PUBLIC_IP:51820 + +# Routes all traffic through VPN (or use 10.0.0.0/24 for only WireGuard network) +AllowedIPs = 10.0.0.0/24 + +# Keep connection alive +PersistentKeepalive = 25 +``` + +### 5. Start WireGuard Client + +```bash +# Start WireGuard +sudo wg-quick up wg0 + +# Check status +sudo wg show + +# To stop +sudo wg-quick down wg0 + +# Enable on boot (Linux) +sudo systemctl enable wg-quick@wg0 +sudo systemctl start wg-quick@wg0 +``` + +**Windows:** Use the WireGuard GUI application and import the configuration file. + +### 6. Test Connection + +```bash +# From local machine, ping the VPS WireGuard IP +ping 10.0.0.1 + +# Test Redis connection +redis-cli -h 10.0.0.1 -p 6379 ping + +# Test PostgreSQL connection +psql -h 10.0.0.1 -p 5432 -U postgres -d gallery +``` + +## Update Application Configuration + +### VPS Configuration + +Update `docker-compose.vps.yml` to bind services to WireGuard IP: + +```yaml +redis: + command: redis-server --appendonly yes --bind 0.0.0.0 + # Redis will be accessible via WireGuard IP (10.0.0.1) + +db: + # PostgreSQL will be accessible via WireGuard IP (10.0.0.1) +``` + +### Local GPU Worker Configuration + +Update `.env` on local machine: + +```env +# Use WireGuard IP instead of public IP +celery.broker_url=redis://10.0.0.1:6379/0 +celery.result_backend=redis://10.0.0.1:6379/0 + +# Database connection via WireGuard +database.host=10.0.0.1 +database.port=5432 +``` + +## Security Best Practices + +1. **Use Strong Keys:** WireGuard keys are already cryptographically strong, but keep them secure. + +2. **Firewall Rules:** Only allow WireGuard port (51820/UDP) from trusted IPs if possible. + +3. **Regular Updates:** Keep WireGuard updated: + ```bash + sudo apt update && sudo apt upgrade wireguard + ``` + +4. **Backup Keys:** Securely backup your private keys (encrypted). + +5. **Monitor Connections:** + ```bash + # Check active connections + sudo wg show + + # Monitor traffic + sudo watch -n 1 'wg show wg0 dump' + ``` + +## Troubleshooting + +### Connection Issues + +1. **Check WireGuard status:** + ```bash + sudo wg show + sudo systemctl status wg-quick@wg0 + ``` + +2. **Verify firewall rules:** + ```bash + sudo ufw status + sudo iptables -L -n + ``` + +3. **Check routing:** + ```bash + ip route show + ``` + +4. **Test connectivity:** + ```bash + ping 10.0.0.1 # From client to server + ping 10.0.0.2 # From server to client + ``` + +### Docker Network Issues + +If Docker containers can't reach WireGuard IPs: + +1. **Enable IP forwarding in Docker:** + ```bash + # Check Docker network settings + docker network inspect gallery_network + ``` + +2. **Use host network mode (if needed):** + ```yaml + # In docker-compose, for services that need WireGuard access + network_mode: host + ``` + +3. **Or configure Docker to use WireGuard interface:** + ```bash + # Add route in Docker network + docker network create --subnet=172.20.0.0/16 gallery_network + ``` + +## Quick Reference + +**Server IP:** 10.0.0.1 +**Client IP:** 10.0.0.2 +**Network:** 10.0.0.0/24 +**Port:** 51820/UDP + +**Server commands:** +```bash +sudo systemctl start wg-quick@wg0 +sudo systemctl stop wg-quick@wg0 +sudo wg show +``` + +**Client commands:** +```bash +sudo wg-quick up wg0 +sudo wg-quick down wg0 +sudo wg show +``` diff --git a/docs/prd.md b/docs/prd.md new file mode 100644 index 0000000..12bb6db --- /dev/null +++ b/docs/prd.md @@ -0,0 +1,86 @@ +# Dokument Wymagań Produktu (PRD) – System "SmartGallery" (MVP) + +## 1. Wstęp i Cel Projektu + +**Główny problem:** Organizacja i katalogowanie zdjęć jest procesem czasochłonnym, co prowadzi do gromadzenia nieuporządkowanych zbiorów danych ("cyfrowy bałagan"), do których użytkownicy rzadko wracają. + +**Cel produktu:** Stworzenie inteligentnej galerii zdjęć w przeglądarce, która automatyzuje proces tagowania i opisywania zdjęć przy użyciu AI, umożliwiając błyskawiczne wyszukiwanie i łatwe udostępnianie wspomnień. + +--- + +## 2. Grupa Docelowa i Persona + +* **Użytkownik domowy:** Osoba posiadająca tysiące zdjęć rozproszonych na różnych nośnikach, chcąca szybko odnaleźć konkretne momenty (np. "zdjęcie psa z wakacji") bez ręcznego opisywania każdego pliku. + +--- + +## 3. Zakres Funkcjonalny MVP + +### 3.1. Zarządzanie Strukturą + +* **Hierarchia:** Galeria -> Album -> Zdjęcie. +* **Wgrywanie (Upload):** +* Pojedyncze pliki (do 5MB) lub paczki ZIP/TAR (do 100MB). +* **Mapowanie folderów:** ZIP wgrany do Galerii tworzy Albumy na podstawie nazw folderów wewnątrz (1 poziom głębokości). +* Obsługa formatów: JPG, PNG, HEIC/HEIF (z automatyczną konwersją do JPG). + + +* **Masowe działania (Bulk Actions):** Seryjne przenoszenie zdjęć między albumami, usuwanie oraz masowa edycja tagów. + +### 3.2. Automatyzacja i AI + +* **Obiekty (Yolo):** Automatyczne rozpoznawanie obiektów i przypisywanie tagów (opcjonalne przy uploadzie, możliwe do wyzwolenia później). +* **Tekst (OCR):** Ekstrakcja tekstu ze zdjęć (polski/angielski) w celu umożliwienia wyszukiwania po treści (np. szyldy, dokumenty). +* **Metadane EXIF:** Automatyczne pobieranie danych: data, model aparatu, ISO (opcjonalne, za zgodą użytkownika). +* **Pętla zwrotna:** Usuwanie/edycja tagu przez użytkownika jest sygnałem dla systemu do poprawy algorytmu. + +### 3.3. Wyszukiwanie i Przeglądanie + +* **Live Search:** Wyszukiwanie po tytułach, tagach AI, tekście z OCR oraz parametrach EXIF (np. ISO). +* **Widok:** Responsywny Grid View z optymalizacją wyświetlania (miniaturki). +* **Ulubione:** System oznaczania zdjęć "gwiazdką". + +### 3.4. Udostępnianie i Prywatność + +* **Poziomy dostępu:** Galerie Prywatne (domyślne) oraz Publiczne (wyszukiwalne dla wszystkich). +* **Udostępnianie:** Przekazywanie dostępu poprzez adres e-mail. Udostępnione galerie trafiają do sekcji "Udostępnione dla mnie" u odbiorcy (tylko widok). +* **Bezpieczeństwo:** Przycisk "Zgłoś nadużycie" (Report button) w galeriach publicznych. + +--- + +## 4. Specyfikacja Techniczna i Ograniczenia + +| Cecha | Specyfikacja | +| --- | --- | +| **Platforma** | Przeglądarka internetowa (RWD) | +| **Storage** | Limit 5GB na użytkownika (wliczając kosz) | +| **Logowanie** | Lokalne konto + Social Login (Google, Facebook itp.) | +| **Usuwanie** | Soft delete (5 dni w koszu), po tym czasie permanentne usunięcie | +| **Eksport** | Paczka ZIP (struktura folderów) + plik JSON z metadanymi | +| **Powiadomienia** | Systemowe (wewnątrz aplikacji) o nowych udostępnieniach | + +--- + +## 5. User Stories + +1. **Jako użytkownik**, chcę wgrać cały folder z wakacji w formacie ZIP, aby system automatycznie stworzył mi album o tej samej nazwie. +2. **Jako użytkownik**, chcę wpisać słowo "pizza" w wyszukiwarkę, aby znaleźć zdjęcia z restauracji, mimo że sam ich nie opisałem. +3. **Jako użytkownik**, chcę udostępnić galerię rodzinie po ich mailach, aby tylko oni mogli oglądać moje prywatne zdjęcia. +4. **Jako fotograf-amator**, chcę przefiltrować zdjęcia po ISO 800, aby sprawdzić jakość zdjęć nocnych z mojego aparatu. + +--- + +## 6. Kryteria Sukcesu (KPI) + +* **Adopcja:** Średnia liczba zdjęć wgranych przez aktywnego użytkownika w ciągu pierwszego miesiąca. +* **Efektywność AI:** Stosunek tagów zaakceptowanych/pozostawionych przez użytkownika do tagów usuniętych ręcznie. +* **Precyzja wyszukiwania:** Liczba wyszukiwań zakończonych kliknięciem w wynik (zdjęcie). + +--- + +## 7. Zarządzanie Ryzykiem + +* **Ryzyko:** Przekroczenie limitu 5GB przez użytkowników domowych. +* **Mitygacja:** Wyraźny wskaźnik zajętości miejsca w UI i proaktywne sugestie czyszczenia kosza. +* **Ryzyko:** Błędna konwersja HEIC. +* **Mitygacja:** Implementacja sprawdzonych bibliotek serwerowych (np. ImageMagick/Libheif) i informowanie o błędach pojedynczych plików. diff --git a/main.py b/main.py new file mode 100644 index 0000000..58ce375 --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from gallery!") + + +if __name__ == "__main__": + main() diff --git a/nginx/conf.d/ssl.conf.example b/nginx/conf.d/ssl.conf.example new file mode 100644 index 0000000..e0a42b9 --- /dev/null +++ b/nginx/conf.d/ssl.conf.example @@ -0,0 +1,63 @@ +# HTTPS server – copy to ssl.conf and place certs in nginx/ssl/ +# See docs/SSL_NGINX.md for generating certificates and enabling SSL. +# Match secure_link_secret with the value in nginx.conf (or GALLERY_SIGNED_URL_SECRET in Django). + +server { + listen 443 ssl http2; + server_name _; # Replace with your domain, e.g. gallery.example.com + + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + location /static/ { + alias /static/; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + location /media/ { + secure_link $arg_st,$arg_e; + set $secure_link_secret "super-important-secret-key"; + secure_link_md5 "$uri$secure_link_expires$secure_link_secret"; + if ($secure_link = "") { return 466; } + if ($secure_link = "0") { return 467; } + proxy_pass http://seaweedfs:8888/buckets/gallery/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + expires 7d; + add_header Cache-Control "public"; + } + + location /flower/ { + proxy_pass http://flower/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location / { + proxy_pass http://django; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..c9129d6 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,138 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; +} +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 100M; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss; + + upstream django { + server web:8000; + } + + upstream flower { + server flower:5555; + } + + server { + listen 80; + server_name localhost; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Static files + location /static/ { + alias /static/; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # Media files with signed URL validation + # This validates signed URLs generated by Django for SeaweedFS files + # Note: secure_link_secret must match GALLERY_SIGNED_URL_SECRET or SECRET_KEY from Django + location /media/ { + # Enable secure_link module for signed URL validation + # Format: secure_link $arg_st,$arg_e; + # Where: st=signature, e=expires timestamp + secure_link $arg_st,$arg_e; + + # Set the secret key (should be passed as environment variable) + # In production, use: set $secure_link_secret "your-secret-key"; + # Or pass via environment variable in docker-compose + set $secure_link_secret "super-important-secret-key"; + # MD5 hash of: $uri + $secure_link_expires + $secure_link_secret + # This must match the secret used in Django (GALLERY_SIGNED_URL_SECRET or SECRET_KEY) + secure_link_md5 "$uri$secure_link_expires$secure_link_secret"; + + + # If signature is invalid or expired, return 403 + + # add_header X-Debug-Link "$secure_link" always; + # add_header X-Debug-Expires "$secure_link_expires" always; + # add_header X-Debug-URI "$uri" always; + # add_header X-Debug-Secret "$secure_link_secret" always; + # add_header X-Debug-MD5 "$uri$secure_link_expires$secure_link_secret" always; + # add_header X-Debug-st "$arg_st" always; + # add_header X-Debug-e "$arg_e" always; + if ($secure_link = "") { + return 466; + } + if ($secure_link = "0") { + return 467; + } + # rewrite ^/media/(.*)$ /$1 break; + + # NO trailing slash here + # proxy_pass http://seaweedfs:8888; + + # Proxy to SeaweedFS or serve from local storage + # Option 1: Proxy to SeaweedFS (recommended) + # rewrite ^/media/(.*)$ /buckets/$1 break; + proxy_pass http://seaweedfs:8888/buckets/gallery/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # Option 2: Serve from local storage (if files are synced) + # alias /media/; + + expires 7d; + add_header Cache-Control "public"; + } + + # Flower (Celery monitoring) + location /flower/ { + proxy_pass http://flower/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # Proxy to Django + location / { + proxy_pass http://django; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } + } + + # Optional HTTPS: copy nginx/conf.d/ssl.conf.example to ssl.conf and add certs to nginx/ssl/ + include /etc/nginx/conf.d/*.conf; +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f19f20e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,64 @@ +[project] +name = "gallery" +version = "0.1.0" +description = "SmartGallery - Intelligent Photo Gallery with AI Tagging" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "Django>=5.0.0", + "psycopg2-binary>=2.9.9", + "django-storages>=1.14.2", + "boto3>=1.34.0", + "Pillow>=10.2.0", + "python-dotenv>=1.0.0", + "gunicorn>=21.2.0", + "django-allauth>=0.57.0", + "django-allauth[socialaccount]>=0.57.0", + "djangorestframework>=3.14.0", + "celery>=5.3.0", + "flower>=2.0.0", + "redis>=5.0.0", + "pyyaml>=6.0.0", + "requests==2.32.5", + "requests-oauthlib==2.0.0", + "python3-openid==3.2.0", + "pyjwt==2.10.1", + "python3-saml==1.16.0", +] + +[tool.setuptools.packages.find] +# Packages config and gallery live under app/ (so pip install -e . finds them) +where = ["app"] +include = ["config*", "gallery*"] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-django>=4.8.0", + "pytest-cov>=4.1.0", + "pytest-xdist>=3.5.0", + "pytest-playwright>=0.4.0", + "factory-boy>=3.3.0", + "faker>=22.0.0", +] + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.test_settings" +pythonpath = ["app"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "--strict-markers", + "--tb=short", + "--reuse-db", + "--nomigrations", + "-v", +] +testpaths = ["app/tests"] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", + "unit: marks tests as unit tests", + "e2e: end-to-end tests (Playwright; run with -m e2e)", +] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..ba4334e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,60 @@ +[pytest] +# Django settings module - use test_settings for tests +DJANGO_SETTINGS_MODULE = config.test_settings + +# Add app/ to Python path so config and gallery are importable (CI and local) +pythonpath = app + +# Test discovery patterns +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +# Test paths (relative to pytest.ini location) +testpaths = app/tests + +# Command line options +# Coverage is optional - remove --cov flags if you don't want coverage by default +# Note: --reuse-db is not used with SQLite in-memory database +addopts = + --strict-markers + --tb=short + --nomigrations + -v + +# Markers for test categorization +markers = + slow: marks tests as slow (deselect with '-m "not slow"') + integration: marks tests as integration tests + unit: marks tests as unit tests + e2e: end-to-end tests (Playwright; run with -m e2e) + requires_db: marks tests that require database access + requires_redis: marks tests that require Redis + requires_celery: marks tests that require Celery + +# Logging configuration +log_cli = true +log_cli_level = INFO +log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s +log_cli_date_format = %Y-%m-%d %H:%M:%S + +# Coverage configuration +[coverage:run] +source = app +omit = + */migrations/* + */tests/* + */test_*.py + */__pycache__/* + */venv/* + */env/* + +[coverage:report] +exclude_lines = + pragma: no cover + def __repr__ + raise AssertionError + raise NotImplementedError + if __name__ == .__main__.: + if TYPE_CHECKING: + @abstractmethod diff --git a/requirements-gpu-paddle.txt b/requirements-gpu-paddle.txt new file mode 100644 index 0000000..fefec61 --- /dev/null +++ b/requirements-gpu-paddle.txt @@ -0,0 +1,3 @@ +# PaddlePaddle GPU and PaddleOCR +--extra-index-url https://www.paddlepaddle.org.cn/packages/stable/cu130 +paddlepaddle-gpu>=3.0.0 diff --git a/requirements-gpu-pypi.txt b/requirements-gpu-pypi.txt new file mode 100644 index 0000000..6be7273 --- /dev/null +++ b/requirements-gpu-pypi.txt @@ -0,0 +1,5 @@ +# PyPI-only GPU deps (no extra index) +ultralytics +opencv-python-headless +opencv-contrib-python-headless +paddleocr>=3.0.0 \ No newline at end of file diff --git a/requirements-gpu-pytorch.txt b/requirements-gpu-pytorch.txt new file mode 100644 index 0000000..ed350c0 --- /dev/null +++ b/requirements-gpu-pytorch.txt @@ -0,0 +1,5 @@ +# PyTorch with CUDA 13.0 +--extra-index-url https://download.pytorch.org/whl/cu130 +torch +torchvision +torchaudio diff --git a/scripts/download_yolo_models.py b/scripts/download_yolo_models.py new file mode 100644 index 0000000..3e20876 --- /dev/null +++ b/scripts/download_yolo_models.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +""" +Script to pre-download YOLO models during Docker build. +This ensures models are available in the image and don't need to be downloaded at runtime. +""" +import os +import sys +from pathlib import Path + +try: + from ultralytics import YOLO + + # Set cache directory (models are typically cached in ~/.ultralytics) + # We'll use a directory in /app to ensure it's part of the image + cache_dir = Path("/app/.ultralytics") + cache_dir.mkdir(parents=True, exist_ok=True) + + # Set environment variable for ultralytics cache + os.environ['ULTRALYTICS_CACHE_DIR'] = str(cache_dir) + + print("=" * 60) + print("Downloading YOLO models during Docker build...") + print("=" * 60) + + # List of models to download (you can customize this) + models_to_download = [ + ('yolov8n.pt', 'YOLOv8n (nano) - smallest, fastest'), + ('yolov8s.pt', 'YOLOv8s (small) - good balance'), + ('yolov8m.pt', 'YOLOv8m (medium) - better accuracy'), + # Uncomment if you need larger models: + # ('yolov8l.pt', 'YOLOv8l (large) - high accuracy'), + # ('yolov8x.pt', 'YOLOv8x (extra large) - best accuracy'), + ] + + downloaded_models = [] + + for model_name, description in models_to_download: + try: + print(f"\n[{len(downloaded_models) + 1}/{len(models_to_download)}] {description}") + print(f"Downloading {model_name}...") + model = YOLO(model_name) + model_path = getattr(model, 'ckpt_path', model_name) + print(f"✓ {model_name} downloaded successfully") + print(f" Location: {model_path}") + downloaded_models.append(model_name) + except Exception as e: + print(f"✗ Failed to download {model_name}: {e}") + # Continue with other models even if one fails + continue + + print("\n" + "=" * 60) + if downloaded_models: + print(f"✓ Successfully downloaded {len(downloaded_models)} model(s):") + for model in downloaded_models: + print(f" - {model}") + print(f"\nModels cached in: {cache_dir}") + else: + print("✗ No models were downloaded successfully!") + sys.exit(1) + print("=" * 60) + +except ImportError as e: + print(f"✗ Error: ultralytics package not installed: {e}") + print("Make sure requirements-gpu-pypi.txt includes 'ultralytics' (Celery GPU image)") + sys.exit(1) +except Exception as e: + print(f"✗ Unexpected error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/scripts/setup_seaweedfs_auth.py b/scripts/setup_seaweedfs_auth.py new file mode 100644 index 0000000..44cb685 --- /dev/null +++ b/scripts/setup_seaweedfs_auth.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +Setup SeaweedFS S3 authentication by creating access keys via IAM API. + +This script creates access keys for SeaweedFS S3 API and outputs them +for use in your .env file or config.yaml. + +Usage: + python scripts/setup_seaweedfs_auth.py [--endpoint URL] [--identity IDENTITY] [--output-format FORMAT] + +Examples: + # Create keys with default settings + python scripts/setup_seaweedfs_auth.py + + # Create keys for specific identity + python scripts/setup_seaweedfs_auth.py --identity admin + + # Output in .env format + python scripts/setup_seaweedfs_auth.py --output-format env + + # Output in YAML format + python scripts/setup_seaweedfs_auth.py --output-format yaml +""" +import argparse +import json +import sys +from urllib.parse import urljoin +from urllib.request import urlopen, Request +from urllib.error import URLError, HTTPError + + +def create_access_key(endpoint, identity="admin"): + """ + Create an access key via SeaweedFS IAM API. + + Args: + endpoint: SeaweedFS S3/IAM endpoint URL (e.g., http://localhost:8333) + identity: Identity name for the access key (default: "admin") + + Returns: + dict: Response containing access_key_id and secret_access_key + """ + url = urljoin(endpoint.rstrip('/') + '/', f'iam/createAccessKey?identity={identity}') + + try: + request = Request(url, method='POST') + with urlopen(request, timeout=10) as response: + data = json.loads(response.read().decode()) + return data + except HTTPError as e: + print(f"Error: HTTP {e.code} - {e.reason}", file=sys.stderr) + if e.code == 404: + print("Make sure SeaweedFS is running and IAM API is enabled.", file=sys.stderr) + sys.exit(1) + except URLError as e: + print(f"Error: Could not connect to SeaweedFS at {endpoint}", file=sys.stderr) + print(f"Details: {e.reason}", file=sys.stderr) + sys.exit(1) + except json.JSONDecodeError as e: + print(f"Error: Invalid JSON response from SeaweedFS", file=sys.stderr) + print(f"Details: {e}", file=sys.stderr) + sys.exit(1) + + +def format_output(data, output_format): + """Format the access key data for output.""" + access_key_id = data.get('accessKeyId', '') + secret_access_key = data.get('secretAccessKey', '') + + if not access_key_id or not secret_access_key: + print("Error: Invalid response from SeaweedFS IAM API", file=sys.stderr) + print(f"Response: {data}", file=sys.stderr) + sys.exit(1) + + if output_format == 'env': + print("# SeaweedFS S3 Access Keys (add to .env file)") + print(f"storage.s3.access_key_id={access_key_id}") + print(f"storage.s3.secret_access_key={secret_access_key}") + elif output_format == 'yaml': + print("# SeaweedFS S3 Access Keys (add to config.yaml)") + print("storage:") + print(" s3:") + print(f" access_key_id: {access_key_id}") + print(f" secret_access_key: {secret_access_key}") + else: # default/json + print(json.dumps({ + 'access_key_id': access_key_id, + 'secret_access_key': secret_access_key + }, indent=2)) + + +def main(): + parser = argparse.ArgumentParser( + description='Create SeaweedFS S3 access keys via IAM API', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + parser.add_argument( + '--endpoint', + default='http://localhost:8333', + help='SeaweedFS S3/IAM endpoint URL (default: http://localhost:8333)' + ) + parser.add_argument( + '--identity', + default='admin', + help='Identity name for the access key (default: admin)' + ) + parser.add_argument( + '--output-format', + choices=['env', 'yaml', 'json'], + default='json', + help='Output format (default: json)' + ) + + args = parser.parse_args() + + print(f"Creating access key for identity '{args.identity}'...", file=sys.stderr) + print(f"Connecting to {args.endpoint}...", file=sys.stderr) + + data = create_access_key(args.endpoint, args.identity) + + print("\n✓ Access key created successfully!\n", file=sys.stderr) + format_output(data, args.output_format) + + print("\n⚠️ IMPORTANT: Save these credentials securely!", file=sys.stderr) + print(" Add them to your .env file or config.yaml", file=sys.stderr) + + +if __name__ == '__main__': + main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..05d3d82 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1734 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[[package]] +name = "amqp" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, +] + +[[package]] +name = "asgiref" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "billiard" +version = "4.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, +] + +[[package]] +name = "boto3" +version = "1.42.36" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/06/50ff808cf4f40efada8edc20f9d563ab287864423c874dfb94f755a60c52/boto3-1.42.36.tar.gz", hash = "sha256:a4eb51105c8c5d7b2bc2a9e2316e69baf69a55611275b9f189c0cf59f1aae171", size = 112839, upload-time = "2026-01-27T20:38:26.992Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/d1/35d12f04a7792e2f5e9ddff3c7b60493a32027761380dee7f24ee8ae80cc/boto3-1.42.36-py3-none-any.whl", hash = "sha256:e0ff6f2747bfdec63405b35ea185a7aea35239c3f4fe99e4d29368a6de9c4a84", size = 140604, upload-time = "2026-01-27T20:38:25.349Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.36" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/4e/b24089cf7a77d38886ac4fbae300a3c4c6d68c1b9ccb66af03cb07b6c35c/botocore-1.42.36.tar.gz", hash = "sha256:2ebd89cc75927944e2cee51b7adce749f38e0cb269a758a6464a27f8bcca65fb", size = 14909073, upload-time = "2026-01-27T20:38:16.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/e8/f14d25bd768187424b385bc6a44e2ed5e96964e461ba019add03e48713c7/botocore-1.42.36-py3-none-any.whl", hash = "sha256:2cfae4c482e5e87bd835ab4289b711490c161ba57e852c06b65a03e7c25e08eb", size = 14583066, upload-time = "2026-01-27T20:38:14.02Z" }, +] + +[[package]] +name = "celery" +version = "5.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "billiard" }, + { name = "click" }, + { name = "click-didyoumean" }, + { name = "click-plugins" }, + { name = "click-repl" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "kombu" }, + { name = "python-dateutil" }, + { name = "tzlocal" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/9d/3d13596519cfa7207a6f9834f4b082554845eb3cd2684b5f8535d50c7c44/celery-5.6.2.tar.gz", hash = "sha256:4a8921c3fcf2ad76317d3b29020772103581ed2454c4c042cc55dcc43585009b", size = 1718802, upload-time = "2026-01-04T12:35:58.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/bd/9ecd619e456ae4ba73b6583cc313f26152afae13e9a82ac4fe7f8856bfd1/celery-5.6.2-py3-none-any.whl", hash = "sha256:3ffafacbe056951b629c7abcf9064c4a2366de0bdfc9fdba421b97ebb68619a5", size = 445502, upload-time = "2026-01-04T12:35:55.894Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[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" }, +] + +[[package]] +name = "charset-normalizer" +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/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.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "click-didyoumean" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, +] + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, +] + +[[package]] +name = "click-repl" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/49/349848445b0e53660e258acbcc9b0d014895b6739237920886672240f84b/coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3", size = 826523, upload-time = "2026-01-25T13:00:04.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/2d/63e37369c8e81a643afe54f76073b020f7b97ddbe698c5c944b51b0a2bc5/coverage-7.13.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4af3b01763909f477ea17c962e2cca8f39b350a4e46e3a30838b2c12e31b81b", size = 218842, upload-time = "2026-01-25T12:57:15.3Z" }, + { url = "https://files.pythonhosted.org/packages/57/06/86ce882a8d58cbcb3030e298788988e618da35420d16a8c66dac34f138d0/coverage-7.13.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36393bd2841fa0b59498f75466ee9bdec4f770d3254f031f23e8fd8e140ffdd2", size = 219360, upload-time = "2026-01-25T12:57:17.572Z" }, + { url = "https://files.pythonhosted.org/packages/cd/84/70b0eb1ee19ca4ef559c559054c59e5b2ae4ec9af61398670189e5d276e9/coverage-7.13.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9cc7573518b7e2186bd229b1a0fe24a807273798832c27032c4510f47ffdb896", size = 246123, upload-time = "2026-01-25T12:57:19.087Z" }, + { url = "https://files.pythonhosted.org/packages/35/fb/05b9830c2e8275ebc031e0019387cda99113e62bb500ab328bb72578183b/coverage-7.13.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca9566769b69a5e216a4e176d54b9df88f29d750c5b78dbb899e379b4e14b30c", size = 247930, upload-time = "2026-01-25T12:57:20.929Z" }, + { url = "https://files.pythonhosted.org/packages/81/aa/3f37858ca2eed4f09b10ca3c6ddc9041be0a475626cd7fd2712f4a2d526f/coverage-7.13.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c9bdea644e94fd66d75a6f7e9a97bb822371e1fe7eadae2cacd50fcbc28e4dc", size = 249804, upload-time = "2026-01-25T12:57:22.904Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b3/c904f40c56e60a2d9678a5ee8df3d906d297d15fb8bec5756c3b0a67e2df/coverage-7.13.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5bd447332ec4f45838c1ad42268ce21ca87c40deb86eabd59888859b66be22a5", size = 246815, upload-time = "2026-01-25T12:57:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/41/91/ddc1c5394ca7fd086342486440bfdd6b9e9bda512bf774599c7c7a0081e0/coverage-7.13.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7c79ad5c28a16a1277e1187cf83ea8dafdcc689a784228a7d390f19776db7c31", size = 247843, upload-time = "2026-01-25T12:57:26.544Z" }, + { url = "https://files.pythonhosted.org/packages/87/d2/cdff8f4cd33697883c224ea8e003e9c77c0f1a837dc41d95a94dd26aad67/coverage-7.13.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:76e06ccacd1fb6ada5d076ed98a8c6f66e2e6acd3df02819e2ee29fd637b76ad", size = 245850, upload-time = "2026-01-25T12:57:28.507Z" }, + { url = "https://files.pythonhosted.org/packages/f5/42/e837febb7866bf2553ab53dd62ed52f9bb36d60c7e017c55376ad21fbb05/coverage-7.13.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:49d49e9a5e9f4dc3d3dac95278a020afa6d6bdd41f63608a76fa05a719d5b66f", size = 246116, upload-time = "2026-01-25T12:57:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/09/b1/4a3f935d7df154df02ff4f71af8d61298d713a7ba305d050ae475bfbdde2/coverage-7.13.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed2bce0e7bfa53f7b0b01c722da289ef6ad4c18ebd52b1f93704c21f116360c8", size = 246720, upload-time = "2026-01-25T12:57:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/538a6fd44c515f1c5197a3f078094cbaf2ce9f945df5b44e29d95c864bff/coverage-7.13.2-cp310-cp310-win32.whl", hash = "sha256:1574983178b35b9af4db4a9f7328a18a14a0a0ce76ffaa1c1bacb4cc82089a7c", size = 221465, upload-time = "2026-01-25T12:57:33.511Z" }, + { url = "https://files.pythonhosted.org/packages/5e/09/4b63a024295f326ec1a40ec8def27799300ce8775b1cbf0d33b1790605c4/coverage-7.13.2-cp310-cp310-win_amd64.whl", hash = "sha256:a360a8baeb038928ceb996f5623a4cd508728f8f13e08d4e96ce161702f3dd99", size = 222397, upload-time = "2026-01-25T12:57:34.927Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/abca50583a8975bb6e1c59eff67ed8e48bb127c07dad5c28d9e96ccc09ec/coverage-7.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e", size = 218971, upload-time = "2026-01-25T12:57:36.953Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0e/b6489f344d99cd1e5b4d5e1be52dfd3f8a3dc5112aa6c33948da8cabad4e/coverage-7.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e", size = 219473, upload-time = "2026-01-25T12:57:38.934Z" }, + { url = "https://files.pythonhosted.org/packages/17/11/db2f414915a8e4ec53f60b17956c27f21fb68fcf20f8a455ce7c2ccec638/coverage-7.13.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b780090d15fd58f07cf2011943e25a5f0c1c894384b13a216b6c86c8a8a7c508", size = 249896, upload-time = "2026-01-25T12:57:40.365Z" }, + { url = "https://files.pythonhosted.org/packages/80/06/0823fe93913663c017e508e8810c998c8ebd3ec2a5a85d2c3754297bdede/coverage-7.13.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b", size = 251810, upload-time = "2026-01-25T12:57:42.045Z" }, + { url = "https://files.pythonhosted.org/packages/61/dc/b151c3cc41b28cdf7f0166c5fa1271cbc305a8ec0124cce4b04f74791a18/coverage-7.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6326e18e9a553e674d948536a04a80d850a5eeefe2aae2e6d7cf05d54046c01b", size = 253920, upload-time = "2026-01-25T12:57:44.026Z" }, + { url = "https://files.pythonhosted.org/packages/2d/35/e83de0556e54a4729a2b94ea816f74ce08732e81945024adee46851c2264/coverage-7.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f", size = 250025, upload-time = "2026-01-25T12:57:45.624Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/af2eb9c3926ce3ea0d58a0d2516fcbdacf7a9fc9559fe63076beaf3f2596/coverage-7.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3", size = 251612, upload-time = "2026-01-25T12:57:47.713Z" }, + { url = "https://files.pythonhosted.org/packages/26/62/5be2e25f3d6c711d23b71296f8b44c978d4c8b4e5b26871abfc164297502/coverage-7.13.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b", size = 249670, upload-time = "2026-01-25T12:57:49.378Z" }, + { url = "https://files.pythonhosted.org/packages/b3/51/400d1b09a8344199f9b6a6fc1868005d766b7ea95e7882e494fa862ca69c/coverage-7.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1", size = 249395, upload-time = "2026-01-25T12:57:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/e0/36/f02234bc6e5230e2f0a63fd125d0a2093c73ef20fdf681c7af62a140e4e7/coverage-7.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059", size = 250298, upload-time = "2026-01-25T12:57:52.287Z" }, + { url = "https://files.pythonhosted.org/packages/b0/06/713110d3dd3151b93611c9cbfc65c15b4156b44f927fced49ac0b20b32a4/coverage-7.13.2-cp311-cp311-win32.whl", hash = "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031", size = 221485, upload-time = "2026-01-25T12:57:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/16/0c/3ae6255fa1ebcb7dec19c9a59e85ef5f34566d1265c70af5b2fc981da834/coverage-7.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e", size = 222421, upload-time = "2026-01-25T12:57:55.433Z" }, + { url = "https://files.pythonhosted.org/packages/b5/37/fabc3179af4d61d89ea47bd04333fec735cd5e8b59baad44fed9fc4170d7/coverage-7.13.2-cp311-cp311-win_arm64.whl", hash = "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28", size = 221088, upload-time = "2026-01-25T12:57:57.41Z" }, + { url = "https://files.pythonhosted.org/packages/46/39/e92a35f7800222d3f7b2cbb7bbc3b65672ae8d501cb31801b2d2bd7acdf1/coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d", size = 219142, upload-time = "2026-01-25T12:58:00.448Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/8bf9e9309c4c996e65c52a7c5a112707ecdd9fbaf49e10b5a705a402bbb4/coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3", size = 219503, upload-time = "2026-01-25T12:58:02.451Z" }, + { url = "https://files.pythonhosted.org/packages/87/93/17661e06b7b37580923f3f12406ac91d78aeed293fb6da0b69cc7957582f/coverage-7.13.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99", size = 251006, upload-time = "2026-01-25T12:58:04.059Z" }, + { url = "https://files.pythonhosted.org/packages/12/f0/f9e59fb8c310171497f379e25db060abef9fa605e09d63157eebec102676/coverage-7.13.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f", size = 253750, upload-time = "2026-01-25T12:58:05.574Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b1/1935e31add2232663cf7edd8269548b122a7d100047ff93475dbaaae673e/coverage-7.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f", size = 254862, upload-time = "2026-01-25T12:58:07.647Z" }, + { url = "https://files.pythonhosted.org/packages/af/59/b5e97071ec13df5f45da2b3391b6cdbec78ba20757bc92580a5b3d5fa53c/coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa", size = 251420, upload-time = "2026-01-25T12:58:09.309Z" }, + { url = "https://files.pythonhosted.org/packages/3f/75/9495932f87469d013dc515fb0ce1aac5fa97766f38f6b1a1deb1ee7b7f3a/coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce", size = 252786, upload-time = "2026-01-25T12:58:10.909Z" }, + { url = "https://files.pythonhosted.org/packages/6a/59/af550721f0eb62f46f7b8cb7e6f1860592189267b1c411a4e3a057caacee/coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94", size = 250928, upload-time = "2026-01-25T12:58:12.449Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b1/21b4445709aae500be4ab43bbcfb4e53dc0811c3396dcb11bf9f23fd0226/coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5", size = 250496, upload-time = "2026-01-25T12:58:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/0f5d89dfe0392990e4f3980adbde3eb34885bc1effb2dc369e0bf385e389/coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b", size = 252373, upload-time = "2026-01-25T12:58:15.976Z" }, + { url = "https://files.pythonhosted.org/packages/01/c9/0cf1a6a57a9968cc049a6b896693faa523c638a5314b1fc374eb2b2ac904/coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41", size = 221696, upload-time = "2026-01-25T12:58:17.517Z" }, + { url = "https://files.pythonhosted.org/packages/4d/05/d7540bf983f09d32803911afed135524570f8c47bb394bf6206c1dc3a786/coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e", size = 222504, upload-time = "2026-01-25T12:58:19.115Z" }, + { url = "https://files.pythonhosted.org/packages/15/8b/1a9f037a736ced0a12aacf6330cdaad5008081142a7070bc58b0f7930cbc/coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894", size = 221120, upload-time = "2026-01-25T12:58:21.334Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f0/3d3eac7568ab6096ff23791a526b0048a1ff3f49d0e236b2af6fb6558e88/coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6", size = 219168, upload-time = "2026-01-25T12:58:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a6/f8b5cfeddbab95fdef4dcd682d82e5dcff7a112ced57a959f89537ee9995/coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc", size = 219537, upload-time = "2026-01-25T12:58:24.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e6/8d8e6e0c516c838229d1e41cadcec91745f4b1031d4db17ce0043a0423b4/coverage-7.13.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f", size = 250528, upload-time = "2026-01-25T12:58:26.567Z" }, + { url = "https://files.pythonhosted.org/packages/8e/78/befa6640f74092b86961f957f26504c8fba3d7da57cc2ab7407391870495/coverage-7.13.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1", size = 253132, upload-time = "2026-01-25T12:58:28.251Z" }, + { url = "https://files.pythonhosted.org/packages/9d/10/1630db1edd8ce675124a2ee0f7becc603d2bb7b345c2387b4b95c6907094/coverage-7.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9", size = 254374, upload-time = "2026-01-25T12:58:30.294Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1d/0d9381647b1e8e6d310ac4140be9c428a0277330991e0c35bdd751e338a4/coverage-7.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c", size = 250762, upload-time = "2026-01-25T12:58:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5636dfc9a7c871ee8776af83ee33b4c26bc508ad6cee1e89b6419a366582/coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5", size = 252502, upload-time = "2026-01-25T12:58:33.961Z" }, + { url = "https://files.pythonhosted.org/packages/02/2a/7ff2884d79d420cbb2d12fed6fff727b6d0ef27253140d3cdbbd03187ee0/coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4", size = 250463, upload-time = "2026-01-25T12:58:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/ba51087db645b6c7261570400fc62c89a16278763f36ba618dc8657a187b/coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c", size = 250288, upload-time = "2026-01-25T12:58:37.226Z" }, + { url = "https://files.pythonhosted.org/packages/03/07/44e6f428551c4d9faf63ebcefe49b30e5c89d1be96f6a3abd86a52da9d15/coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31", size = 252063, upload-time = "2026-01-25T12:58:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/c2/67/35b730ad7e1859dd57e834d1bc06080d22d2f87457d53f692fce3f24a5a9/coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8", size = 221716, upload-time = "2026-01-25T12:58:40.484Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/e5fcf5a97c72f45fc14829237a6550bf49d0ab882ac90e04b12a69db76b4/coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb", size = 222522, upload-time = "2026-01-25T12:58:43.247Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/25d7b2f946d239dd2d6644ca2cc060d24f97551e2af13b6c24c722ae5f97/coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557", size = 221145, upload-time = "2026-01-25T12:58:45Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f7/080376c029c8f76fadfe43911d0daffa0cbdc9f9418a0eead70c56fb7f4b/coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e", size = 219861, upload-time = "2026-01-25T12:58:46.586Z" }, + { url = "https://files.pythonhosted.org/packages/42/11/0b5e315af5ab35f4c4a70e64d3314e4eec25eefc6dec13be3a7d5ffe8ac5/coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7", size = 220207, upload-time = "2026-01-25T12:58:48.277Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0c/0874d0318fb1062117acbef06a09cf8b63f3060c22265adaad24b36306b7/coverage-7.13.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3", size = 261504, upload-time = "2026-01-25T12:58:49.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/5e/1cd72c22ecb30751e43a72f40ba50fcef1b7e93e3ea823bd9feda8e51f9a/coverage-7.13.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3", size = 263582, upload-time = "2026-01-25T12:58:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/8acf356707c7a42df4d0657020308e23e5a07397e81492640c186268497c/coverage-7.13.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421", size = 266008, upload-time = "2026-01-25T12:58:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/41/41/ea1730af99960309423c6ea8d6a4f1fa5564b2d97bd1d29dda4b42611f04/coverage-7.13.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5", size = 260762, upload-time = "2026-01-25T12:58:55.372Z" }, + { url = "https://files.pythonhosted.org/packages/22/fa/02884d2080ba71db64fdc127b311db60e01fe6ba797d9c8363725e39f4d5/coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23", size = 263571, upload-time = "2026-01-25T12:58:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6b/4083aaaeba9b3112f55ac57c2ce7001dc4d8fa3fcc228a39f09cc84ede27/coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c", size = 261200, upload-time = "2026-01-25T12:58:59.255Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d2/aea92fa36d61955e8c416ede9cf9bf142aa196f3aea214bb67f85235a050/coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f", size = 260095, upload-time = "2026-01-25T12:59:01.066Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ae/04ffe96a80f107ea21b22b2367175c621da920063260a1c22f9452fd7866/coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573", size = 262284, upload-time = "2026-01-25T12:59:02.802Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7a/6f354dcd7dfc41297791d6fb4e0d618acb55810bde2c1fd14b3939e05c2b/coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343", size = 222389, upload-time = "2026-01-25T12:59:04.563Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d5/080ad292a4a3d3daf411574be0a1f56d6dee2c4fdf6b005342be9fac807f/coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47", size = 223450, upload-time = "2026-01-25T12:59:06.677Z" }, + { url = "https://files.pythonhosted.org/packages/88/96/df576fbacc522e9fb8d1c4b7a7fc62eb734be56e2cba1d88d2eabe08ea3f/coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7", size = 221707, upload-time = "2026-01-25T12:59:08.363Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/1da9e51a0775634b04fcc11eb25c002fc58ee4f92ce2e8512f94ac5fc5bf/coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef", size = 219213, upload-time = "2026-01-25T12:59:11.909Z" }, + { url = "https://files.pythonhosted.org/packages/46/35/b3caac3ebbd10230fea5a33012b27d19e999a17c9285c4228b4b2e35b7da/coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f", size = 219549, upload-time = "2026-01-25T12:59:13.638Z" }, + { url = "https://files.pythonhosted.org/packages/76/9c/e1cf7def1bdc72c1907e60703983a588f9558434a2ff94615747bd73c192/coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5", size = 250586, upload-time = "2026-01-25T12:59:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ba/49/f54ec02ed12be66c8d8897270505759e057b0c68564a65c429ccdd1f139e/coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4", size = 253093, upload-time = "2026-01-25T12:59:17.491Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5e/aaf86be3e181d907e23c0f61fccaeb38de8e6f6b47aed92bf57d8fc9c034/coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27", size = 254446, upload-time = "2026-01-25T12:59:19.752Z" }, + { url = "https://files.pythonhosted.org/packages/28/c8/a5fa01460e2d75b0c853b392080d6829d3ca8b5ab31e158fa0501bc7c708/coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548", size = 250615, upload-time = "2026-01-25T12:59:21.928Z" }, + { url = "https://files.pythonhosted.org/packages/86/0b/6d56315a55f7062bb66410732c24879ccb2ec527ab6630246de5fe45a1df/coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660", size = 252452, upload-time = "2026-01-25T12:59:23.592Z" }, + { url = "https://files.pythonhosted.org/packages/30/19/9bc550363ebc6b0ea121977ee44d05ecd1e8bf79018b8444f1028701c563/coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92", size = 250418, upload-time = "2026-01-25T12:59:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/580530a31ca2f0cc6f07a8f2ab5460785b02bb11bdf815d4c4d37a4c5169/coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82", size = 250231, upload-time = "2026-01-25T12:59:27.888Z" }, + { url = "https://files.pythonhosted.org/packages/e2/42/dd9093f919dc3088cb472893651884bd675e3df3d38a43f9053656dca9a2/coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892", size = 251888, upload-time = "2026-01-25T12:59:29.636Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a6/0af4053e6e819774626e133c3d6f70fae4d44884bfc4b126cb647baee8d3/coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe", size = 221968, upload-time = "2026-01-25T12:59:31.424Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/5aff1e1f80d55862442855517bb8ad8ad3a68639441ff6287dde6a58558b/coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859", size = 222783, upload-time = "2026-01-25T12:59:33.118Z" }, + { url = "https://files.pythonhosted.org/packages/de/20/09abafb24f84b3292cc658728803416c15b79f9ee5e68d25238a895b07d9/coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6", size = 221348, upload-time = "2026-01-25T12:59:34.939Z" }, + { url = "https://files.pythonhosted.org/packages/b6/60/a3820c7232db63be060e4019017cd3426751c2699dab3c62819cdbcea387/coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b", size = 219950, upload-time = "2026-01-25T12:59:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/fd/37/e4ef5975fdeb86b1e56db9a82f41b032e3d93a840ebaf4064f39e770d5c5/coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417", size = 220209, upload-time = "2026-01-25T12:59:38.339Z" }, + { url = "https://files.pythonhosted.org/packages/54/df/d40e091d00c51adca1e251d3b60a8b464112efa3004949e96a74d7c19a64/coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee", size = 261576, upload-time = "2026-01-25T12:59:40.446Z" }, + { url = "https://files.pythonhosted.org/packages/c5/44/5259c4bed54e3392e5c176121af9f71919d96dde853386e7730e705f3520/coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1", size = 263704, upload-time = "2026-01-25T12:59:42.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/ae9f005827abcbe2c70157459ae86053971c9fa14617b63903abbdce26d9/coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d", size = 266109, upload-time = "2026-01-25T12:59:44.073Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c0/8e279c1c0f5b1eaa3ad9b0fb7a5637fc0379ea7d85a781c0fe0bb3cfc2ab/coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6", size = 260686, upload-time = "2026-01-25T12:59:45.804Z" }, + { url = "https://files.pythonhosted.org/packages/b2/47/3a8112627e9d863e7cddd72894171c929e94491a597811725befdcd76bce/coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a", size = 263568, upload-time = "2026-01-25T12:59:47.929Z" }, + { url = "https://files.pythonhosted.org/packages/92/bc/7ea367d84afa3120afc3ce6de294fd2dcd33b51e2e7fbe4bbfd200f2cb8c/coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04", size = 261174, upload-time = "2026-01-25T12:59:49.717Z" }, + { url = "https://files.pythonhosted.org/packages/33/b7/f1092dcecb6637e31cc2db099581ee5c61a17647849bae6b8261a2b78430/coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f", size = 260017, upload-time = "2026-01-25T12:59:51.463Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/f3d07d4b95fbe1a2ef0958c15da614f7e4f557720132de34d2dc3aa7e911/coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f", size = 262337, upload-time = "2026-01-25T12:59:53.407Z" }, + { url = "https://files.pythonhosted.org/packages/e0/db/b0d5b2873a07cb1e06a55d998697c0a5a540dcefbf353774c99eb3874513/coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3", size = 222749, upload-time = "2026-01-25T12:59:56.316Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2f/838a5394c082ac57d85f57f6aba53093b30d9089781df72412126505716f/coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba", size = 223857, upload-time = "2026-01-25T12:59:58.201Z" }, + { url = "https://files.pythonhosted.org/packages/44/d4/b608243e76ead3a4298824b50922b89ef793e50069ce30316a65c1b4d7ef/coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c", size = 221881, upload-time = "2026-01-25T13:00:00.449Z" }, + { url = "https://files.pythonhosted.org/packages/d2/db/d291e30fdf7ea617a335531e72294e0c723356d7fdde8fba00610a76bda9/coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5", size = 210943, upload-time = "2026-01-25T13:00:02.388Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[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 = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "django" +version = "5.2.10" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version < '3.12'" }, + { name = "sqlparse", marker = "python_full_version < '3.12'" }, + { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/e5/2671df24bf0ded831768ef79532e5a7922485411a5696f6d979568591a37/django-5.2.10.tar.gz", hash = "sha256:74df100784c288c50a2b5cad59631d71214f40f72051d5af3fdf220c20bdbbbe", size = 10880754, upload-time = "2026-01-06T18:55:26.817Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/de/f1a7cd896daec85832136ab509d9b2a6daed4939dbe26313af3e95fc5f5e/django-5.2.10-py3-none-any.whl", hash = "sha256:cf85067a64250c95d5f9067b056c5eaa80591929f7e16fbcd997746e40d6c45c", size = 8290820, upload-time = "2026-01-06T18:55:20.009Z" }, +] + +[[package]] +name = "django" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version >= '3.12'" }, + { name = "sqlparse", marker = "python_full_version >= '3.12'" }, + { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/9b/016f7e55e855ee738a352b05139d4f8b278d0b451bd01ebef07456ef3b0e/django-6.0.1.tar.gz", hash = "sha256:ed76a7af4da21551573b3d9dfc1f53e20dd2e6c7d70a3adc93eedb6338130a5f", size = 11069565, upload-time = "2026-01-06T18:55:53.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/b5/814ed98bd21235c116fd3436a7ed44d47560329a6d694ec8aac2982dbb93/django-6.0.1-py3-none-any.whl", hash = "sha256:a92a4ff14f664a896f9849009cb8afaca7abe0d6fc53325f3d1895a15253433d", size = 8338791, upload-time = "2026-01-06T18:55:46.175Z" }, +] + +[[package]] +name = "django-allauth" +version = "65.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django", version = "5.2.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/9b/061a6ac65c602eb721b13fbf9c665b20fb900f113a03ec8521b5fcf16b83/django_allauth-65.14.0.tar.gz", hash = "sha256:5529227aba2b1377d900e9274a3f24496c645e65400fbae3cad5789944bc4d0b", size = 1991909, upload-time = "2026-01-17T18:43:12.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/c8/2f959ff8466913d95ba72eb4a29bd7998d28a559786033a97b5bbdda2b81/django_allauth-65.14.0-py3-none-any.whl", hash = "sha256:448f5f7877f95fcbe1657256510fe7822d7871f202521a29e23ef937f3325a97", size = 1793052, upload-time = "2026-01-17T18:43:08.954Z" }, +] + +[package.optional-dependencies] +socialaccount = [ + { name = "oauthlib" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] + +[[package]] +name = "django-storages" +version = "1.14.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django", version = "5.2.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587, upload-time = "2025-04-02T02:34:55.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" }, +] + +[[package]] +name = "djangorestframework" +version = "3.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django", version = "5.2.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "factory-boy" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "faker" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146, upload-time = "2025-02-03T09:49:04.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036, upload-time = "2025-02-03T09:49:01.659Z" }, +] + +[[package]] +name = "faker" +version = "40.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/77/1c3ff07b6739b9a1d23ca01ec0a90a309a33b78e345a3eb52f9ce9240e36/faker-40.1.2.tar.gz", hash = "sha256:b76a68163aa5f171d260fc24827a8349bc1db672f6a665359e8d0095e8135d30", size = 1949802, upload-time = "2026-01-13T20:51:49.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/ec/91a434c8a53d40c3598966621dea9c50512bec6ce8e76fa1751015e74cef/faker-40.1.2-py3-none-any.whl", hash = "sha256:93503165c165d330260e4379fd6dc07c94da90c611ed3191a0174d2ab9966a42", size = 1985633, upload-time = "2026-01-13T20:51:47.982Z" }, +] + +[[package]] +name = "flower" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "celery" }, + { name = "humanize" }, + { name = "prometheus-client" }, + { name = "pytz" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a1/357f1b5d8946deafdcfdd604f51baae9de10aafa2908d0b7322597155f92/flower-2.0.1.tar.gz", hash = "sha256:5ab717b979530770c16afb48b50d2a98d23c3e9fe39851dcf6bc4d01845a02a0", size = 3220408, upload-time = "2023-08-13T14:37:46.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/ff/ee2f67c0ff146ec98b5df1df637b2bc2d17beeb05df9f427a67bd7a7d79c/flower-2.0.1-py2.py3-none-any.whl", hash = "sha256:9db2c621eeefbc844c8dd88be64aef61e84e2deb29b271e02ab2b5b9f01068e2", size = 383553, upload-time = "2023-08-13T14:37:41.552Z" }, +] + +[[package]] +name = "gallery" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "boto3" }, + { name = "celery" }, + { name = "django", version = "5.2.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "django-allauth", extra = ["socialaccount"] }, + { name = "django-storages" }, + { name = "djangorestframework" }, + { name = "flower" }, + { name = "gunicorn" }, + { name = "pillow" }, + { name = "psycopg2-binary" }, + { name = "pyjwt" }, + { name = "python-dotenv" }, + { name = "python3-openid" }, + { name = "python3-saml" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "requests" }, + { name = "requests-oauthlib" }, +] + +[package.optional-dependencies] +dev = [ + { name = "factory-boy" }, + { name = "faker" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-django" }, + { name = "pytest-playwright" }, + { name = "pytest-xdist" }, +] + +[package.metadata] +requires-dist = [ + { name = "boto3", specifier = ">=1.34.0" }, + { name = "celery", specifier = ">=5.3.0" }, + { name = "django", specifier = ">=5.0.0" }, + { name = "django-allauth", specifier = ">=0.57.0" }, + { name = "django-allauth", extras = ["socialaccount"], specifier = ">=0.57.0" }, + { name = "django-storages", specifier = ">=1.14.2" }, + { name = "djangorestframework", specifier = ">=3.14.0" }, + { name = "factory-boy", marker = "extra == 'dev'", specifier = ">=3.3.0" }, + { name = "faker", marker = "extra == 'dev'", specifier = ">=22.0.0" }, + { name = "flower", specifier = ">=2.0.0" }, + { name = "gunicorn", specifier = ">=21.2.0" }, + { name = "pillow", specifier = ">=10.2.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.9" }, + { name = "pyjwt", specifier = "==2.10.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, + { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.8.0" }, + { name = "pytest-playwright", marker = "extra == 'dev'", specifier = ">=0.4.0" }, + { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.5.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "python3-openid", specifier = "==3.2.0" }, + { name = "python3-saml", specifier = "==1.16.0" }, + { name = "pyyaml", specifier = ">=6.0.0" }, + { name = "redis", specifier = ">=5.0.0" }, + { name = "requests", specifier = "==2.32.5" }, + { name = "requests-oauthlib", specifier = "==2.0.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "greenlet" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/65/5b235b40581ad75ab97dcd8b4218022ae8e3ab77c13c919f1a1dfe9171fd/greenlet-3.3.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:04bee4775f40ecefcdaa9d115ab44736cd4b9c5fba733575bfe9379419582e13", size = 273723, upload-time = "2026-01-23T15:30:37.521Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ad/eb4729b85cba2d29499e0a04ca6fbdd8f540afd7be142fd571eea43d712f/greenlet-3.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e1457f4fed12a50e427988a07f0f9df53cf0ee8da23fab16e6732c2ec909d4", size = 574874, upload-time = "2026-01-23T16:00:54.551Z" }, + { url = "https://files.pythonhosted.org/packages/87/32/57cad7fe4c8b82fdaa098c89498ef85ad92dfbb09d5eb713adedfc2ae1f5/greenlet-3.3.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:070472cd156f0656f86f92e954591644e158fd65aa415ffbe2d44ca77656a8f5", size = 586309, upload-time = "2026-01-23T16:05:25.18Z" }, + { url = "https://files.pythonhosted.org/packages/66/66/f041005cb87055e62b0d68680e88ec1a57f4688523d5e2fb305841bc8307/greenlet-3.3.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1108b61b06b5224656121c3c8ee8876161c491cbe74e5c519e0634c837cf93d5", size = 597461, upload-time = "2026-01-23T16:15:51.943Z" }, + { url = "https://files.pythonhosted.org/packages/87/eb/8a1ec2da4d55824f160594a75a9d8354a5fe0a300fb1c48e7944265217e1/greenlet-3.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a300354f27dd86bae5fbf7002e6dd2b3255cd372e9242c933faf5e859b703fe", size = 586985, upload-time = "2026-01-23T15:32:47.968Z" }, + { url = "https://files.pythonhosted.org/packages/15/1c/0621dd4321dd8c351372ee8f9308136acb628600658a49be1b7504208738/greenlet-3.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e84b51cbebf9ae573b5fbd15df88887815e3253fc000a7d0ff95170e8f7e9729", size = 1547271, upload-time = "2026-01-23T16:04:18.977Z" }, + { url = "https://files.pythonhosted.org/packages/9d/53/24047f8924c83bea7a59c8678d9571209c6bfe5f4c17c94a78c06024e9f2/greenlet-3.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0093bd1a06d899892427217f0ff2a3c8f306182b8c754336d32e2d587c131b4", size = 1613427, upload-time = "2026-01-23T15:33:44.428Z" }, + { url = "https://files.pythonhosted.org/packages/ff/07/ac9bf1ec008916d1a3373cae212884c1dcff4a4ba0d41127ce81a8deb4e9/greenlet-3.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:7932f5f57609b6a3b82cc11877709aa7a98e3308983ed93552a1c377069b20c8", size = 226100, upload-time = "2026-01-23T15:30:56.957Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" }, + { url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" }, + { url = "https://files.pythonhosted.org/packages/70/ae/e2d5f0e59b94a2269b68a629173263fa40b63da32f5c231307c349315871/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f", size = 601161, upload-time = "2026-01-23T16:15:53.456Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" }, + { url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" }, + { url = "https://files.pythonhosted.org/packages/1f/54/dcf9f737b96606f82f8dd05becfb8d238db0633dd7397d542a296fe9cad3/greenlet-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:32e4ca9777c5addcbf42ff3915d99030d8e00173a56f80001fb3875998fe410b", size = 226462, upload-time = "2026-01-23T15:36:50.422Z" }, + { url = "https://files.pythonhosted.org/packages/91/37/61e1015cf944ddd2337447d8e97fb423ac9bc21f9963fb5f206b53d65649/greenlet-3.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:da19609432f353fed186cc1b85e9440db93d489f198b4bdf42ae19cc9d9ac9b4", size = 225715, upload-time = "2026-01-23T15:33:17.298Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" }, + { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, + { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/34/2f/5e0e41f33c69655300a5e54aeb637cf8ff57f1786a3aba374eacc0228c1d/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a", size = 227156, upload-time = "2026-01-23T15:34:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, + { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, + { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, + { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, + { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" }, + { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" }, + { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, + { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, + { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, + { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, + { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, + { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, +] + +[[package]] +name = "gunicorn" +version = "24.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/0a/10739c03537ec5b131a867bf94df2e412b437696c7e5d26970e2198a80d2/gunicorn-24.1.1.tar.gz", hash = "sha256:f006d110e5cb3102859b4f5cd48335dbd9cc28d0d27cd24ddbdafa6c60929408", size = 287567, upload-time = "2026-01-24T01:15:31.359Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/90/cfe637677916fc6f53cd2b05d5746e249f683e1fa14c9e745a88c66f7290/gunicorn-24.1.1-py3-none-any.whl", hash = "sha256:757f6b621fc4f7581a90600b2cd9df593461f06a41d7259cb9b94499dc4095a8", size = 114920, upload-time = "2026-01-24T01:15:29.656Z" }, +] + +[[package]] +name = "humanize" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/66/a3921783d54be8a6870ac4ccffcd15c4dc0dd7fcce51c6d63b8c63935276/humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10", size = 83599, upload-time = "2025-12-20T20:16:13.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +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 = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "kombu" +version = "5.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "amqp" }, + { name = "packaging" }, + { name = "tzdata" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/8a/f8192a08237ef2fb1b19733f709db88a4c43bc8ab8357f01cb41a27e7f6a/lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388", size = 8590589, upload-time = "2025-09-22T04:00:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/27bcd07ae17ff5e5536e8d88f4c7d581b48963817a13de11f3ac3329bfa2/lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153", size = 4629671, upload-time = "2025-09-22T04:00:15.411Z" }, + { url = "https://files.pythonhosted.org/packages/02/5a/a7d53b3291c324e0b6e48f3c797be63836cc52156ddf8f33cd72aac78866/lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31", size = 4999961, upload-time = "2025-09-22T04:00:17.619Z" }, + { url = "https://files.pythonhosted.org/packages/f5/55/d465e9b89df1761674d8672bb3e4ae2c47033b01ec243964b6e334c6743f/lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9", size = 5157087, upload-time = "2025-09-22T04:00:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/38/3073cd7e3e8dfc3ba3c3a139e33bee3a82de2bfb0925714351ad3d255c13/lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8", size = 5067620, upload-time = "2025-09-22T04:00:21.877Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d3/1e001588c5e2205637b08985597827d3827dbaaece16348c8822bfe61c29/lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba", size = 5406664, upload-time = "2025-09-22T04:00:23.714Z" }, + { url = "https://files.pythonhosted.org/packages/20/cf/cab09478699b003857ed6ebfe95e9fb9fa3d3c25f1353b905c9b73cfb624/lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c", size = 5289397, upload-time = "2025-09-22T04:00:25.544Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/02a2d0c38ac9a8b9f9e5e1bbd3f24b3f426044ad618b552e9549ee91bd63/lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c", size = 4772178, upload-time = "2025-09-22T04:00:27.602Z" }, + { url = "https://files.pythonhosted.org/packages/56/87/e1ceadcc031ec4aa605fe95476892d0b0ba3b7f8c7dcdf88fdeff59a9c86/lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321", size = 5358148, upload-time = "2025-09-22T04:00:29.323Z" }, + { url = "https://files.pythonhosted.org/packages/fe/13/5bb6cf42bb228353fd4ac5f162c6a84fd68a4d6f67c1031c8cf97e131fc6/lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1", size = 5112035, upload-time = "2025-09-22T04:00:31.061Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e2/ea0498552102e59834e297c5c6dff8d8ded3db72ed5e8aad77871476f073/lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34", size = 4799111, upload-time = "2025-09-22T04:00:33.11Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9e/8de42b52a73abb8af86c66c969b3b4c2a96567b6ac74637c037d2e3baa60/lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a", size = 5351662, upload-time = "2025-09-22T04:00:35.237Z" }, + { url = "https://files.pythonhosted.org/packages/28/a2/de776a573dfb15114509a37351937c367530865edb10a90189d0b4b9b70a/lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c", size = 5314973, upload-time = "2025-09-22T04:00:37.086Z" }, + { url = "https://files.pythonhosted.org/packages/50/a0/3ae1b1f8964c271b5eec91db2043cf8c6c0bce101ebb2a633b51b044db6c/lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b", size = 3611953, upload-time = "2025-09-22T04:00:39.224Z" }, + { url = "https://files.pythonhosted.org/packages/d1/70/bd42491f0634aad41bdfc1e46f5cff98825fb6185688dc82baa35d509f1a/lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0", size = 4032695, upload-time = "2025-09-22T04:00:41.402Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d0/05c6a72299f54c2c561a6c6cbb2f512e047fca20ea97a05e57931f194ac4/lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5", size = 3680051, upload-time = "2025-09-22T04:00:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, + { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, + { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, + { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, + { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, + { url = "https://files.pythonhosted.org/packages/e7/9c/780c9a8fce3f04690b374f72f41306866b0400b9d0fdf3e17aaa37887eed/lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6", size = 3939264, upload-time = "2025-09-22T04:04:32.892Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5a/1ab260c00adf645d8bf7dec7f920f744b032f69130c681302821d5debea6/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba", size = 4216435, upload-time = "2025-09-22T04:04:34.907Z" }, + { url = "https://files.pythonhosted.org/packages/f2/37/565f3b3d7ffede22874b6d86be1a1763d00f4ea9fc5b9b6ccb11e4ec8612/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5", size = 4325913, upload-time = "2025-09-22T04:04:37.205Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/f3a1b169b2fb9d03467e2e3c0c752ea30e993be440a068b125fc7dd248b0/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4", size = 4269357, upload-time = "2025-09-22T04:04:39.322Z" }, + { url = "https://files.pythonhosted.org/packages/77/a2/585a28fe3e67daa1cf2f06f34490d556d121c25d500b10082a7db96e3bcd/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d", size = 4412295, upload-time = "2025-09-22T04:04:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/a57dd8bcebd7c69386c20263830d4fa72d27e6b72a229ef7a48e88952d9a/lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d", size = 3516913, upload-time = "2025-09-22T04:04:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, + { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/41/f73d92b6b883a579e79600d391f2e21cb0df767b2714ecbd2952315dfeef/pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd", size = 5304089, upload-time = "2026-01-02T09:10:24.953Z" }, + { url = "https://files.pythonhosted.org/packages/94/55/7aca2891560188656e4a91ed9adba305e914a4496800da6b5c0a15f09edf/pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0", size = 4657815, upload-time = "2026-01-02T09:10:27.063Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d2/b28221abaa7b4c40b7dba948f0f6a708bd7342c4d47ce342f0ea39643974/pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8", size = 6222593, upload-time = "2026-01-02T09:10:29.115Z" }, + { url = "https://files.pythonhosted.org/packages/71/b8/7a61fb234df6a9b0b479f69e66901209d89ff72a435b49933f9122f94cac/pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1", size = 8027579, upload-time = "2026-01-02T09:10:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/55c751a57cc524a15a0e3db20e5cde517582359508d62305a627e77fd295/pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda", size = 6335760, upload-time = "2026-01-02T09:10:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7c/60e3e6f5e5891a1a06b4c910f742ac862377a6fe842f7184df4a274ce7bf/pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7", size = 7027127, upload-time = "2026-01-02T09:10:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/06/37/49d47266ba50b00c27ba63a7c898f1bb41a29627ced8c09e25f19ebec0ff/pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a", size = 6449896, upload-time = "2026-01-02T09:10:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/67fd87d2913902462cd9b79c6211c25bfe95fcf5783d06e1367d6d9a741f/pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef", size = 7151345, upload-time = "2026-01-02T09:10:39.064Z" }, + { url = "https://files.pythonhosted.org/packages/bd/15/f8c7abf82af68b29f50d77c227e7a1f87ce02fdc66ded9bf603bc3b41180/pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09", size = 6325568, upload-time = "2026-01-02T09:10:41.035Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/7d1c0e160b6b5ac2605ef7d8be537e28753c0db5363d035948073f5513d7/pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91", size = 7032367, upload-time = "2026-01-02T09:10:43.09Z" }, + { url = "https://files.pythonhosted.org/packages/f4/03/41c038f0d7a06099254c60f618d0ec7be11e79620fc23b8e85e5b31d9a44/pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea", size = 2452345, upload-time = "2026-01-02T09:10:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" }, + { url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" }, + { url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" }, + { url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" }, + { url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" }, + { url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" }, + { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" }, + { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" }, + { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" }, + { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" }, + { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" }, + { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" }, + { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" }, + { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" }, + { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" }, + { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" }, + { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" }, + { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" }, + { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" }, + { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" }, + { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" }, + { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" }, + { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" }, + { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" }, + { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" }, + { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" }, + { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" }, + { url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" }, + { url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" }, + { url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" }, + { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" }, +] + +[[package]] +name = "playwright" +version = "1.58.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" }, + { url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" }, + { url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/f2/8e377d29c2ecf99f6062d35ea606b036e8800720eccfec5fe3dd672c2b24/psycopg2_binary-2.9.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d6fe6b47d0b42ce1c9f1fa3e35bb365011ca22e39db37074458f27921dca40f2", size = 3756506, upload-time = "2025-10-10T11:10:30.144Z" }, + { url = "https://files.pythonhosted.org/packages/24/cc/dc143ea88e4ec9d386106cac05023b69668bd0be20794c613446eaefafe5/psycopg2_binary-2.9.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a6c0e4262e089516603a09474ee13eabf09cb65c332277e39af68f6233911087", size = 3863943, upload-time = "2025-10-10T11:10:34.586Z" }, + { url = "https://files.pythonhosted.org/packages/8c/df/16848771155e7c419c60afeb24950b8aaa3ab09c0a091ec3ccca26a574d0/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c47676e5b485393f069b4d7a811267d3168ce46f988fa602658b8bb901e9e64d", size = 4410873, upload-time = "2025-10-10T11:10:38.951Z" }, + { url = "https://files.pythonhosted.org/packages/43/79/5ef5f32621abd5a541b89b04231fe959a9b327c874a1d41156041c75494b/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a28d8c01a7b27a1e3265b11250ba7557e5f72b5ee9e5f3a2fa8d2949c29bf5d2", size = 4468016, upload-time = "2025-10-10T11:10:43.319Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9b/d7542d0f7ad78f57385971f426704776d7b310f5219ed58da5d605b1892e/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f3f2732cf504a1aa9e9609d02f79bea1067d99edf844ab92c247bbca143303b", size = 4164996, upload-time = "2025-10-10T11:10:46.705Z" }, + { url = "https://files.pythonhosted.org/packages/14/ed/e409388b537fa7414330687936917c522f6a77a13474e4238219fcfd9a84/psycopg2_binary-2.9.11-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:865f9945ed1b3950d968ec4690ce68c55019d79e4497366d36e090327ce7db14", size = 3981881, upload-time = "2025-10-30T02:54:57.182Z" }, + { url = "https://files.pythonhosted.org/packages/bf/30/50e330e63bb05efc6fa7c1447df3e08954894025ca3dcb396ecc6739bc26/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:91537a8df2bde69b1c1db01d6d944c831ca793952e4f57892600e96cee95f2cd", size = 3650857, upload-time = "2025-10-10T11:10:50.112Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e0/4026e4c12bb49dd028756c5b0bc4c572319f2d8f1c9008e0dad8cc9addd7/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4dca1f356a67ecb68c81a7bc7809f1569ad9e152ce7fd02c2f2036862ca9f66b", size = 3296063, upload-time = "2025-10-10T11:10:54.089Z" }, + { url = "https://files.pythonhosted.org/packages/2c/34/eb172be293c886fef5299fe5c3fcf180a05478be89856067881007934a7c/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0da4de5c1ac69d94ed4364b6cbe7190c1a70d325f112ba783d83f8440285f152", size = 3043464, upload-time = "2025-10-30T02:55:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/18/1c/532c5d2cb11986372f14b798a95f2eaafe5779334f6a80589a68b5fcf769/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37d8412565a7267f7d79e29ab66876e55cb5e8e7b3bbf94f8206f6795f8f7e7e", size = 3345378, upload-time = "2025-10-10T11:11:01.039Z" }, + { url = "https://files.pythonhosted.org/packages/70/e7/de420e1cf16f838e1fa17b1120e83afff374c7c0130d088dba6286fcf8ea/psycopg2_binary-2.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:c665f01ec8ab273a61c62beeb8cce3014c214429ced8a308ca1fc410ecac3a39", size = 2713904, upload-time = "2025-10-10T11:11:04.81Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ae/8d8266f6dd183ab4d48b95b9674034e1b482a3f8619b33a0d86438694577/psycopg2_binary-2.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10", size = 3756452, upload-time = "2025-10-10T11:11:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/aa03d327739c1be70e09d01182619aca8ebab5970cd0cfa50dd8b9cec2ac/psycopg2_binary-2.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:763c93ef1df3da6d1a90f86ea7f3f806dc06b21c198fa87c3c25504abec9404a", size = 3863957, upload-time = "2025-10-10T11:11:16.932Z" }, + { url = "https://files.pythonhosted.org/packages/48/89/3fdb5902bdab8868bbedc1c6e6023a4e08112ceac5db97fc2012060e0c9a/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4", size = 4410955, upload-time = "2025-10-10T11:11:21.21Z" }, + { url = "https://files.pythonhosted.org/packages/ce/24/e18339c407a13c72b336e0d9013fbbbde77b6fd13e853979019a1269519c/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7", size = 4468007, upload-time = "2025-10-10T11:11:24.831Z" }, + { url = "https://files.pythonhosted.org/packages/91/7e/b8441e831a0f16c159b5381698f9f7f7ed54b77d57bc9c5f99144cc78232/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee", size = 4165012, upload-time = "2025-10-10T11:11:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/0d/61/4aa89eeb6d751f05178a13da95516c036e27468c5d4d2509bb1e15341c81/psycopg2_binary-2.9.11-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb", size = 3981881, upload-time = "2025-10-30T02:55:07.332Z" }, + { url = "https://files.pythonhosted.org/packages/76/a1/2f5841cae4c635a9459fe7aca8ed771336e9383b6429e05c01267b0774cf/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f", size = 3650985, upload-time = "2025-10-10T11:11:34.975Z" }, + { url = "https://files.pythonhosted.org/packages/84/74/4defcac9d002bca5709951b975173c8c2fa968e1a95dc713f61b3a8d3b6a/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94", size = 3296039, upload-time = "2025-10-10T11:11:40.432Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c2/782a3c64403d8ce35b5c50e1b684412cf94f171dc18111be8c976abd2de1/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f", size = 3043477, upload-time = "2025-10-30T02:55:11.182Z" }, + { url = "https://files.pythonhosted.org/packages/c8/31/36a1d8e702aa35c38fc117c2b8be3f182613faa25d794b8aeaab948d4c03/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908", size = 3345842, upload-time = "2025-10-10T11:11:45.366Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b4/a5375cda5b54cb95ee9b836930fea30ae5a8f14aa97da7821722323d979b/psycopg2_binary-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03", size = 2713894, upload-time = "2025-10-10T11:11:48.775Z" }, + { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, + { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, + { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, + { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[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 = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-base-url" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-django" +version = "4.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202, upload-time = "2025-04-03T18:56:09.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" }, +] + +[[package]] +name = "pytest-playwright" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "playwright" }, + { name = "pytest" }, + { name = "pytest-base-url" }, + { name = "python-slugify" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/6b/913e36aa421b35689ec95ed953ff7e8df3f2ee1c7b8ab2a3f1fd39d95faf/pytest_playwright-0.7.2.tar.gz", hash = "sha256:247b61123b28c7e8febb993a187a07e54f14a9aa04edc166f7a976d88f04c770", size = 16928, upload-time = "2025-11-24T03:43:22.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/61/4d333d8354ea2bea2c2f01bad0a4aa3c1262de20e1241f78e73360e9b620/pytest_playwright-0.7.2-py3-none-any.whl", hash = "sha256:8084e015b2b3ecff483c2160f1c8219b38b66c0d4578b23c0f700d1b0240ea38", size = 16881, upload-time = "2025-11-24T03:43:24.423Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + +[[package]] +name = "python3-openid" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/4a/29feb8da6c44f77007dcd29518fea73a3d5653ee02a587ae1f17f1f5ddb5/python3-openid-3.2.0.tar.gz", hash = "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf", size = 305600, upload-time = "2020-06-29T12:15:49.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a5/c6ba13860bdf5525f1ab01e01cc667578d6f1efc8a1dba355700fb04c29b/python3_openid-3.2.0-py3-none-any.whl", hash = "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b", size = 133681, upload-time = "2020-06-29T12:15:47.502Z" }, +] + +[[package]] +name = "python3-saml" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "isodate" }, + { name = "lxml" }, + { name = "xmlsec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/98/6e0268c3a9893af3d4c5cf670183e0314cd6b5cb034a612d6a7cc5060df8/python3-saml-1.16.0.tar.gz", hash = "sha256:97c9669aecabc283c6e5fb4eb264f446b6e006f5267d01c9734f9d8bffdac133", size = 83468, upload-time = "2023-10-09T10:37:43.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/14/49d9828443b58bd5cc80a454c91b0f867fbf36a24975d501945e6cb9e32f/python3_saml-1.16.0-py3-none-any.whl", hash = "sha256:20b97d11b04f01ee22e98f4a38242e2fea2e28fbc7fbc9bdd57cab5ac7fc2d0d", size = 76155, upload-time = "2023-10-09T10:40:34.001Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "redis" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" }, + { url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" }, + { url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" }, + { url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" }, + { url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "vine" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/6e/62daec357285b927e82263a81f3b4c1790215bc77c42530ce4a69d501a43/wcwidth-0.5.0.tar.gz", hash = "sha256:f89c103c949a693bf563377b2153082bf58e309919dfb7f27b04d862a0089333", size = 246585, upload-time = "2026-01-27T01:31:44.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/3e/45583b67c2ff08ad5a582d316fcb2f11d6cf0a50c7707ac09d212d25bc98/wcwidth-0.5.0-py3-none-any.whl", hash = "sha256:1efe1361b83b0ff7877b81ba57c8562c99cf812158b778988ce17ec061095695", size = 93772, upload-time = "2026-01-27T01:31:43.432Z" }, +] + +[[package]] +name = "xmlsec" +version = "1.3.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/14/538b75379e6ab8f688f14d8663e2ab138d9c778bac4999d155b5f33c71c1/xmlsec-1.3.17.tar.gz", hash = "sha256:f3fac9ae679f66585925cc00c5f6839ae36c1d03157619571dee18acc05b9c01", size = 115637, upload-time = "2025-11-11T16:20:46.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/4d/eff78f7bfb15d02db69fc33709040a37a81b0f187995a4a0263b76f60047/xmlsec-1.3.17-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:00d43d4f68ac6b11f6e1e69bcb389495f54da77bf1168b4de08f4a7785e47bbb", size = 3450575, upload-time = "2025-11-11T16:19:15.67Z" }, + { url = "https://files.pythonhosted.org/packages/eb/06/dd2864ae242477dcca8ee1173d2cdaa97357f5e80b93eb16318b69a68957/xmlsec-1.3.17-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ea2d65749c4c6a35a3ba138debda2f910713a9f4f06b4647510c184c284d7c62", size = 3846698, upload-time = "2025-11-11T16:19:19.675Z" }, + { url = "https://files.pythonhosted.org/packages/48/36/de21872ada14e45290979d9aff07f950f90ab8ab4d42baa53002097f5b11/xmlsec-1.3.17-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d586bb09f146235f82de624ff05fbca76f8aadc627eb9a072df1899317a1a9eb", size = 4420263, upload-time = "2025-11-11T16:19:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/af/26/80c23e5ad0643489c5af5a011415880616c48b59bb4a05646cb1a9a8cb40/xmlsec-1.3.17-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99b5b4b6fe232f2234bcec2bdd533b7ab7030b3ce6cfb8bd7153bf02441c8520", size = 4160109, upload-time = "2025-11-11T16:19:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a6/92f203f394d39236e5e3e96a8dc5a9c3a1b84a4ac51580249ea33d15afd0/xmlsec-1.3.17-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4533780d91f547b841f2522a419da9f2cee2f906dbd4aa58083bc944526a45c", size = 3872742, upload-time = "2025-11-11T16:19:25.76Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f6/52ff78b99c94286ec945a9250207e465f8af293173ec415903add6cbd6db/xmlsec-1.3.17-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:320bf7162e2c442638233da9826af1476049999da1b474b5fe07c60952610131", size = 4458748, upload-time = "2025-11-11T16:19:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/31/1c/3aea63ceaeb862d2d5dada67928779e980baf3d6a86189ddaae74a98d5c8/xmlsec-1.3.17-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bc8d0a75a43a45349b37186ecce5ae028325ede47cbc217802ff3ef4db3f3cb9", size = 4206919, upload-time = "2025-11-11T16:19:29.669Z" }, + { url = "https://files.pythonhosted.org/packages/e4/22/cb81039dc7bc2cbcf04497261d263726331417898c3e1eaec8281c878e42/xmlsec-1.3.17-cp310-cp310-win_amd64.whl", hash = "sha256:5c6d4b2ece9d109591d08128a1656b458e24d9eba6c02c32e93573e14eee2447", size = 2445928, upload-time = "2025-11-11T16:19:31.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/04/d97825e99a8bb1ab29ff59ce249f93d97dcd22a3f2ce624dd21a4e8bdf50/xmlsec-1.3.17-cp310-cp310-win_arm64.whl", hash = "sha256:2aa5081e1e05dcb6029660ddad795c7daebb3c5771001f60850ab24a16a9cf5e", size = 2261486, upload-time = "2025-11-11T16:19:32.835Z" }, + { url = "https://files.pythonhosted.org/packages/28/e4/970614d892749da00df253c370230fd24143028268923a1c35651fb3f962/xmlsec-1.3.17-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4a7ee007c6b55f7621330aee8330ef2dafa4225fce554064571ca826beafe7e", size = 3450577, upload-time = "2025-11-11T16:19:34.159Z" }, + { url = "https://files.pythonhosted.org/packages/50/4a/2f48ad48fecbd49dbbc6f2a5b540cd65277089fd5b8b5d8c7e816c3625c2/xmlsec-1.3.17-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1ef656421d01851618d0fe5518e57469159c14a48e05125f7bd3225631952f9", size = 3846698, upload-time = "2025-11-11T16:19:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/0130e0b711f7443d0abdec403ea5128392cd5b241bb53f4ec41d144d94db/xmlsec-1.3.17-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80fff2251d0e73714435b5860ce200990dffe85466dd91d08d75c4d64ee9967d", size = 4423233, upload-time = "2025-11-11T16:19:37.129Z" }, + { url = "https://files.pythonhosted.org/packages/00/f7/a4e588d61f602f25a51b6004be9a162e36e746fa1cbeb12248794a96766b/xmlsec-1.3.17-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f2bf6bbf04f8a912483d268b4c2727d400d1806d054624da13bee4b9f6fa28a", size = 4163716, upload-time = "2025-11-11T16:19:38.365Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a2/f8c019445134dfc59afb5874d1fc4fe212ec2dc45a8c33806a15b5c0c119/xmlsec-1.3.17-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a603584ceee175036e1bccdbe65d551c0fff67343fd506bfa6cec52bc64d9a75", size = 3875404, upload-time = "2025-11-11T16:19:40.008Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/90c0e26bb9f95799c64874ebee0b43eaf7e5b5ba912bcd87ed4cc46ea514/xmlsec-1.3.17-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:26cc3d81437b51839946d2e93d09371dfd73ed2831dc7e37eff0fb52fc33747c", size = 4460640, upload-time = "2025-11-11T16:19:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/be/7b85b0ff4281779293d93a8bbef70a6b72ba60d8a80d15653bd4967d0c07/xmlsec-1.3.17-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d862f023f56a49c06576be41dfaf213c9ac77e7a344e7f204278c365bb36d00e", size = 4209625, upload-time = "2025-11-11T16:19:43.289Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6d/028472e523c2f667a4634881b65acfa939bc4902ed37e1e9fe1d55d45ec0/xmlsec-1.3.17-cp311-cp311-win_amd64.whl", hash = "sha256:9877303e8c72d7aa2467d1af12e56d67b8fb50d324eda5848e0ec5ee2176aac5", size = 2445935, upload-time = "2025-11-11T16:19:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/f0/01/d36fd82b837167546951e7e088dbd2f0dacf553157d256b2a25802d28a95/xmlsec-1.3.17-cp311-cp311-win_arm64.whl", hash = "sha256:b3f306f5aef47336b8299d8dbee31fa0b2eba4579f9f41396070f7a97d0dcd49", size = 2261485, upload-time = "2025-11-11T16:19:46.212Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a5/d91216f7dbb85cb65cb7249fcc894f5389a8a4843857aff678646cab77fa/xmlsec-1.3.17-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df4a8d7fef3ffe90e572400d47392ea480120e339c292f802830ed09d449e622", size = 3450960, upload-time = "2025-11-11T16:19:47.794Z" }, + { url = "https://files.pythonhosted.org/packages/b7/38/c37bd4e164259e0b271fe4d17d054f31c7287a1e4c47d24ef77d723b3493/xmlsec-1.3.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed63cbd87dd69ebcf3a9f82d87b67818c9a7d656325dd4fb34d6c4dfbaa84017", size = 3846774, upload-time = "2025-11-11T16:19:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ff/83430c5df33c6ad402728a681998c5b2872c090b556a558d02f8cf1d2f24/xmlsec-1.3.17-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c3008b32a15d24b6c9da39bf6ede8dc3122570a640a73795d763aea55a2193e", size = 4425910, upload-time = "2025-11-11T16:19:50.95Z" }, + { url = "https://files.pythonhosted.org/packages/02/41/bb94c7a97ea613b3860f6152bb7efcf5be524d135592e094ecc64ff79228/xmlsec-1.3.17-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a0b9a1dcda547e0340eefa6f4a04b87dbd9e40cd514487f347934f94fd559ab", size = 4169038, upload-time = "2025-11-11T16:19:52.217Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4c/852ba0805df27b7bd1e88e9524d9573b076c3a126e936b1f18c6f22fb968/xmlsec-1.3.17-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a53c14d4bc40b0f0fcc6d7908b88f3cbbcf36e25c392f796d88aee7dee5beea", size = 3876430, upload-time = "2025-11-11T16:19:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f0/08fec6adc65f6911b49b4fa71e920c8f6434f44fdc427c71360e6dd9e9ce/xmlsec-1.3.17-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5346616e1fe1015f7800698c15225c7902f45db199e217af2039a21989aff7e9", size = 4464419, upload-time = "2025-11-11T16:19:54.777Z" }, + { url = "https://files.pythonhosted.org/packages/25/ce/84789ba3929715806deae88f10bc31e1ff904aa735059ee3855c104a142d/xmlsec-1.3.17-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:64c1184d51c8a67e3d1eb3ac477e307a07e2b40fd03cd0c8084b147ea0f342db", size = 4215080, upload-time = "2025-11-11T16:19:56.293Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6e/57b5054187cd2b42e5310dc1f6d209fced456f93dae25345a422b3a290ef/xmlsec-1.3.17-cp312-cp312-win_amd64.whl", hash = "sha256:d360d4adfb53d3adeca398c225cb7e2a73a2246414455937082a1fa19bd8572b", size = 2445872, upload-time = "2025-11-11T16:19:57.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/7b/f64c95df054dd793ae1925f04248abd359b1c26cc2320d67407e7fd26e4d/xmlsec-1.3.17-cp312-cp312-win_arm64.whl", hash = "sha256:eee89c268a35f8a08a8e9abef6f466b97577e94f5cac8bf32c25e97cd5020097", size = 2261464, upload-time = "2025-11-11T16:19:58.937Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/d0c03351bbf776f2272d602272ca9d759d48f0f4e90707987098abb48e14/xmlsec-1.3.17-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:672e41dc7962da4ce84b67aa1c3a008338e3b88332f5484b9911b91cee0997ed", size = 3450899, upload-time = "2025-11-11T16:20:00.29Z" }, + { url = "https://files.pythonhosted.org/packages/50/6e/00db758c40d42ae2d43603552262b1027c02bbac934be26425e820c63c0f/xmlsec-1.3.17-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:72fc6d336dd68d62822c6536ff4b2453fda94ea652eddb4a958ac97b16ac7001", size = 3846790, upload-time = "2025-11-11T16:20:01.515Z" }, + { url = "https://files.pythonhosted.org/packages/4b/91/00cd12243f5f8cccec23e0d9946379861b954bf98c52d3f68b9eb565ba76/xmlsec-1.3.17-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae88c3aaab5704adfdbce913b3a18db1eb96c49c970657cc01c0d1c420ffdec3", size = 4427662, upload-time = "2025-11-11T16:20:02.931Z" }, + { url = "https://files.pythonhosted.org/packages/77/64/d198a8109c11124b01abbd34167dd951896b12392ccfc3f12c40eb3f0c35/xmlsec-1.3.17-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79b471fdd1d3a92b80907828eaa809f6e34023583488b1b8dc3f951529e7a2f8", size = 4170229, upload-time = "2025-11-11T16:20:04.244Z" }, + { url = "https://files.pythonhosted.org/packages/75/a9/3e061f10d0d921102a55b4c0442c8c5af4e01e175ea1584774eeef2e50aa/xmlsec-1.3.17-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:040f28a7aacfdb467df46d423e4af05569e9376bc8c7f6416b0761e16a0e3d0b", size = 3877622, upload-time = "2025-11-11T16:20:05.593Z" }, + { url = "https://files.pythonhosted.org/packages/37/1a/b8a71915bf1d59944d815c92e77a06e9c2dc4dc855a44a3127c86b0dd7f2/xmlsec-1.3.17-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67717fe5151df68987a1387cba11ba28ce19b3bb9a2d10d650277cd910e510e7", size = 4464934, upload-time = "2025-11-11T16:20:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0f/4b9057c6049137256bb972d114d2858fc8b24e72c97e05e26a00d2db8ed2/xmlsec-1.3.17-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9bb6faa4ae0268204cfa6b0c0de1c9121eb606eea8c66c7d7ce62e89a17f9efa", size = 4215150, upload-time = "2025-11-11T16:20:10.008Z" }, + { url = "https://files.pythonhosted.org/packages/95/6b/a2e8bc2f94b90c2904007663c8162423fadd3cd98b7ca1632b66dcdc31cb/xmlsec-1.3.17-cp313-cp313-win_amd64.whl", hash = "sha256:66fe5aaccf68fb85fe0b64277e3f594d6b01ddefb98ef1ceb0a666652d6ec580", size = 2445890, upload-time = "2025-11-11T16:20:11.575Z" }, + { url = "https://files.pythonhosted.org/packages/8c/df/27210baa675eb9e5d80ed43e80d865be8fbf6148ea464d2b4d4ad1ba9f01/xmlsec-1.3.17-cp313-cp313-win_arm64.whl", hash = "sha256:5319d0bdaf9e597a0ba8dfb3840c4ae57e51f462e7620953f32b07df6267f2ba", size = 2261424, upload-time = "2025-11-11T16:20:12.88Z" }, + { url = "https://files.pythonhosted.org/packages/77/2c/0169a383769d563f6582d5b3a2ccf7f612f4bf98cbd417a27287443b63c5/xmlsec-1.3.17-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d0e69291f90b28e9442d8e0e69d3e06cede8a3c44e856413fd284de81ce2888", size = 3450932, upload-time = "2025-11-11T16:20:14.334Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/be65923c5aa3097f422af3d917ffda15590ab0f4c9a5a5d78d520ae7fc9a/xmlsec-1.3.17-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5616ad5016794b0dd41d03eef5b721e31bb306353226b25fc88fedb7d4f7c37e", size = 3847248, upload-time = "2025-11-11T16:20:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/1b/58/24e047e6a5f0c266e949c7c03c2770163038e7abd322c95bfbae021f9477/xmlsec-1.3.17-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4be73fbde421d6188300e02ad92d2d5435c708a35ede8124ebdf6b00330d7cb", size = 4428590, upload-time = "2025-11-11T16:20:18.012Z" }, + { url = "https://files.pythonhosted.org/packages/d6/23/e5212147d227da638311287045c90a47bb560b0552cc7daca0919a870220/xmlsec-1.3.17-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3961102a6ba8250670814bd1086139fb918e03bf146ef85dc8b6084a9b027d1", size = 4169645, upload-time = "2025-11-11T16:20:19.646Z" }, + { url = "https://files.pythonhosted.org/packages/68/5d/ed1f6d18f7c10dc61f791aade218b2271b4fc3092dd499036bc391a32945/xmlsec-1.3.17-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:728058a1623a620811a3cdf2dd4894b5d9413ede20c8ddddf98fdea5eafe9529", size = 3878531, upload-time = "2025-11-11T16:20:20.964Z" }, + { url = "https://files.pythonhosted.org/packages/dd/eb/09050fd1dc109ebe5bfefd0eab0829cab4fae51b3a244949e31dccf144e1/xmlsec-1.3.17-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:593264c192d1836162d75478c8b1cb5874f3b69dcc5bdfac642a0933abefa93a", size = 4464490, upload-time = "2025-11-11T16:20:22.369Z" }, + { url = "https://files.pythonhosted.org/packages/e9/2e/52e9ef2b5c8ef2470e1e3ae3ef89f7ac45eecd267c7b3bab8a7ad7d68af1/xmlsec-1.3.17-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3d1fc1fbe2e8585a3f468cf4154d0ec36cd95a15e68429ad8cc8ccd7c04e84ae", size = 4214358, upload-time = "2025-11-11T16:20:24.073Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cd/5e9061027a203fd083b6058c2948ee1a16bd909d3a0e331e054362ca550e/xmlsec-1.3.17-cp314-cp314-win_amd64.whl", hash = "sha256:e2bf1d07c4f97afeb957f626b8c3ebb8cef300efa0cb95599e936c69a66a1b17", size = 2513252, upload-time = "2025-11-11T16:20:25.738Z" }, + { url = "https://files.pythonhosted.org/packages/93/e9/b2f4b9092434b854bcae0d901c10a7e96d2a12d03cc35dbf7a7b2c91502b/xmlsec-1.3.17-cp314-cp314-win_arm64.whl", hash = "sha256:3a6ced8c7744e896cb5a9fd0156d204df3143a62bae11be91cab8e9743d40eec", size = 2328451, upload-time = "2025-11-11T16:20:27.247Z" }, +]